@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,28 @@
|
|
|
1
|
+
/** Erreur métier dont le message s'adresse directement à l'agent ou à la GUI. */
|
|
2
|
+
export class AnalysisError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "AnalysisError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Analyse, constat ou remarque introuvable. */
|
|
9
|
+
export class NotFoundError extends AnalysisError {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "NotFoundError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Modification basée sur une révision qui n'est plus la révision enregistrée. */
|
|
16
|
+
export class RevisionConflictError extends AnalysisError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "RevisionConflictError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Échec d'accès au dépôt git : chemin invalide, dépôt absent, ref inconnue, commande en erreur. */
|
|
23
|
+
export class GitError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "GitError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AnalysisMode, FileEntry } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
import { type FileDiff } from "../../shared/schemas/diff.schema.js";
|
|
3
|
+
/** Ref de tête utilisée en mode `branch` quand aucune n'est fournie. */
|
|
4
|
+
export declare const DEFAULT_HEAD_REF = "HEAD";
|
|
5
|
+
export interface SnapshotRequest {
|
|
6
|
+
/** Chemin absolu de la racine du dépôt git. */
|
|
7
|
+
repoPath: string;
|
|
8
|
+
mode: AnalysisMode;
|
|
9
|
+
/** Ref de base, requise en mode `branch` ; absente ou "HEAD" en mode `working_tree`. */
|
|
10
|
+
base?: string | null;
|
|
11
|
+
/** Ref de tête en mode `branch`, `HEAD` par défaut ; interdite en mode `working_tree`. */
|
|
12
|
+
head?: string | null;
|
|
13
|
+
}
|
|
14
|
+
/** Snapshot calculé : refs effectives, métadonnées des fichiers et diff figé. */
|
|
15
|
+
export interface ComputedSnapshot {
|
|
16
|
+
repoPath: string;
|
|
17
|
+
mode: AnalysisMode;
|
|
18
|
+
/** Ref de base effective ; "HEAD" en mode `working_tree`. */
|
|
19
|
+
base: string;
|
|
20
|
+
/** Ref de tête effective en mode `branch` ; null en mode `working_tree`. */
|
|
21
|
+
head: string | null;
|
|
22
|
+
baseCommit: string;
|
|
23
|
+
headCommit: string | null;
|
|
24
|
+
snapshotAt: string;
|
|
25
|
+
files: FileEntry[];
|
|
26
|
+
diff: FileDiff[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Calcule le snapshot d'une analyse avec git.
|
|
30
|
+
* - `branch` : diff `base...head` (head vaut `HEAD` par défaut), contenu lu dans le commit de tête ;
|
|
31
|
+
* - `working_tree` : diff de HEAD vers la copie de travail, index compris, fichiers non suivis
|
|
32
|
+
* présentés comme ajoutés, contenu lu sur disque.
|
|
33
|
+
* Les fichiers sont triés par chemin ; `reviewed` vaut false partout.
|
|
34
|
+
*/
|
|
35
|
+
export declare function computeSnapshot(request: SnapshotRequest): Promise<ComputedSnapshot>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { MAX_SNAPSHOT_FILE_BYTES } from "../../shared/schemas/diff.schema.js";
|
|
5
|
+
import { splitLines } from "../../shared/text.js";
|
|
6
|
+
import { parseUnifiedDiff, toPosixPath } from "./diff-parser.js";
|
|
7
|
+
import { GitError } from "./errors.js";
|
|
8
|
+
/** Plafond de la sortie d'une commande git. */
|
|
9
|
+
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
10
|
+
/** Ref de tête utilisée en mode `branch` quand aucune n'est fournie. */
|
|
11
|
+
export const DEFAULT_HEAD_REF = "HEAD";
|
|
12
|
+
function runGit(cwd, args, encoding, input) {
|
|
13
|
+
const fullArgs = ["-c", "core.quotepath=false", ...args];
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = execFile("git", fullArgs, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: encoding === "buffer" ? "buffer" : "utf8", windowsHide: true }, (error, stdout, stderr) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
const code = error.code;
|
|
18
|
+
if (code === "ENOENT") {
|
|
19
|
+
reject(new GitError("The git executable was not found on PATH. Install git and restart the MCP server."));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const detail = String(stderr).trim() || error.message;
|
|
23
|
+
reject(new GitError(`git ${args.join(" ")} failed in ${cwd}: ${detail}`));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
resolve(stdout);
|
|
27
|
+
});
|
|
28
|
+
child.stdin?.end(input ?? "");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Chemin réel et normalisé, comparable entre deux écritures du même répertoire. */
|
|
32
|
+
function canonical(value) {
|
|
33
|
+
const real = path.resolve(fs.realpathSync.native(value));
|
|
34
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
35
|
+
}
|
|
36
|
+
/** Vérifie que `repoPath` est un chemin absolu vers la racine d'un dépôt git. */
|
|
37
|
+
async function assertRepoRoot(repoPath) {
|
|
38
|
+
if (!path.isAbsolute(repoPath)) {
|
|
39
|
+
throw new GitError(`repo_path must be an absolute path, got "${repoPath}".`);
|
|
40
|
+
}
|
|
41
|
+
if (!fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory()) {
|
|
42
|
+
throw new GitError(`repo_path "${repoPath}" does not exist or is not a directory.`);
|
|
43
|
+
}
|
|
44
|
+
let topLevel;
|
|
45
|
+
try {
|
|
46
|
+
topLevel = (await runGit(repoPath, ["rev-parse", "--show-toplevel"], "utf-8")).trim();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof GitError && /not a git repository/i.test(err.message)) {
|
|
50
|
+
throw new GitError(`repo_path "${repoPath}" is not inside a git repository.`);
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
if (canonical(topLevel) !== canonical(repoPath)) {
|
|
55
|
+
throw new GitError(`repo_path "${repoPath}" is not the root of its git repository. Use the repository root: ${path.resolve(topLevel)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Résout une ref en sha de commit ; erreur explicite si elle est inconnue. */
|
|
59
|
+
async function resolveCommit(repoPath, ref, role) {
|
|
60
|
+
if (ref.startsWith("-")) {
|
|
61
|
+
throw new GitError(`Invalid ${role} ref "${ref}": a ref cannot start with "-".`);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return (await runGit(repoPath, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], "utf-8")).trim();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
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
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const DIFF_ARGS = [
|
|
71
|
+
"-c",
|
|
72
|
+
"diff.noprefix=false",
|
|
73
|
+
"-c",
|
|
74
|
+
"diff.mnemonicPrefix=false",
|
|
75
|
+
"diff",
|
|
76
|
+
"--no-color",
|
|
77
|
+
"--no-ext-diff",
|
|
78
|
+
"--no-textconv",
|
|
79
|
+
"--no-relative",
|
|
80
|
+
"--find-renames",
|
|
81
|
+
"--src-prefix=a/",
|
|
82
|
+
"--dst-prefix=b/",
|
|
83
|
+
];
|
|
84
|
+
/** Vrai si le contenu contient un octet nul dans ses 8000 premiers octets (même règle que git). */
|
|
85
|
+
function looksBinary(content) {
|
|
86
|
+
return content.subarray(0, 8000).includes(0);
|
|
87
|
+
}
|
|
88
|
+
/** Hunk unique d'un fichier ajouté, toutes lignes en ajout. */
|
|
89
|
+
function additionHunk(lines) {
|
|
90
|
+
if (lines.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
header: `@@ -0,0 +1,${lines.length} @@`,
|
|
95
|
+
oldStart: 0,
|
|
96
|
+
oldLines: 0,
|
|
97
|
+
newStart: 1,
|
|
98
|
+
newLines: lines.length,
|
|
99
|
+
lines: lines.map((text, index) => ({ type: "add", oldNo: null, newNo: index + 1, text })),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
/** Contenu d'un fichier de la copie de travail ; un lien symbolique donne sa cible, comme dans git. */
|
|
104
|
+
function readWorkingFile(repoPath, filePath) {
|
|
105
|
+
const absolute = path.join(repoPath, ...filePath.split("/"));
|
|
106
|
+
const stat = fs.lstatSync(absolute);
|
|
107
|
+
if (stat.isSymbolicLink())
|
|
108
|
+
return Buffer.from(toPosixPath(fs.readlinkSync(absolute)), "utf-8");
|
|
109
|
+
return fs.readFileSync(absolute);
|
|
110
|
+
}
|
|
111
|
+
/** Texte conservé dans le snapshot, ou null quand le contenu dépasse MAX_SNAPSHOT_FILE_BYTES. */
|
|
112
|
+
function snapshotText(content) {
|
|
113
|
+
if (content.length > MAX_SNAPSHOT_FILE_BYTES)
|
|
114
|
+
return null;
|
|
115
|
+
return content.toString("utf-8");
|
|
116
|
+
}
|
|
117
|
+
function toEntry(parsed, newContent) {
|
|
118
|
+
return {
|
|
119
|
+
entry: {
|
|
120
|
+
path: parsed.path,
|
|
121
|
+
oldPath: parsed.oldPath,
|
|
122
|
+
status: parsed.status,
|
|
123
|
+
additions: parsed.additions,
|
|
124
|
+
deletions: parsed.deletions,
|
|
125
|
+
binary: parsed.binary,
|
|
126
|
+
contentAvailable: newContent !== null,
|
|
127
|
+
lineCount: newContent === null ? 0 : splitLines(newContent).length,
|
|
128
|
+
reviewed: false,
|
|
129
|
+
},
|
|
130
|
+
diff: { path: parsed.path, hunks: parsed.hunks, newContent },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Contenu des fichiers `paths` dans le commit `commit`, en deux commandes git
|
|
135
|
+
* quel que soit leur nombre : `ls-tree` donne l'objet de chaque chemin, puis
|
|
136
|
+
* `cat-file --batch` lit tous les objets d'un coup.
|
|
137
|
+
*/
|
|
138
|
+
async function readCommitFiles(repoPath, commit, paths) {
|
|
139
|
+
const contents = new Map();
|
|
140
|
+
if (paths.length === 0)
|
|
141
|
+
return contents;
|
|
142
|
+
const blobs = new Map();
|
|
143
|
+
for (const entry of (await runGit(repoPath, ["ls-tree", "-r", "-z", "--full-tree", commit], "utf-8")).split("\0")) {
|
|
144
|
+
if (entry === "")
|
|
145
|
+
continue;
|
|
146
|
+
const tab = entry.indexOf("\t");
|
|
147
|
+
const [, type, sha] = entry.slice(0, tab).split(" ");
|
|
148
|
+
if (type === "blob")
|
|
149
|
+
blobs.set(entry.slice(tab + 1), sha);
|
|
150
|
+
}
|
|
151
|
+
const shas = paths.map((filePath) => {
|
|
152
|
+
const sha = blobs.get(filePath);
|
|
153
|
+
if (sha === undefined) {
|
|
154
|
+
throw new GitError(`"${filePath}" is in the diff but is not a file of commit ${commit} (submodule or unreadable entry).`);
|
|
155
|
+
}
|
|
156
|
+
return sha;
|
|
157
|
+
});
|
|
158
|
+
const output = await runGit(repoPath, ["cat-file", "--batch"], "buffer", shas.join("\n") + "\n");
|
|
159
|
+
let offset = 0;
|
|
160
|
+
paths.forEach((filePath, index) => {
|
|
161
|
+
const headerEnd = output.indexOf(0x0a, offset);
|
|
162
|
+
if (headerEnd === -1)
|
|
163
|
+
throw new GitError(`git cat-file --batch ended before the content of "${filePath}".`);
|
|
164
|
+
const header = output.subarray(offset, headerEnd).toString("utf-8");
|
|
165
|
+
const [sha, type, size] = header.split(" ");
|
|
166
|
+
if (sha !== shas[index] || type !== "blob" || size === undefined) {
|
|
167
|
+
throw new GitError(`Unexpected git cat-file --batch header "${header}" for "${filePath}".`);
|
|
168
|
+
}
|
|
169
|
+
const start = headerEnd + 1;
|
|
170
|
+
contents.set(filePath, output.subarray(start, start + Number(size)));
|
|
171
|
+
offset = start + Number(size) + 1;
|
|
172
|
+
});
|
|
173
|
+
return contents;
|
|
174
|
+
}
|
|
175
|
+
async function branchSnapshot(repoPath, base, head) {
|
|
176
|
+
const baseCommit = await resolveCommit(repoPath, base, "base");
|
|
177
|
+
const headCommit = await resolveCommit(repoPath, head, "head");
|
|
178
|
+
const output = await runGit(repoPath, [...DIFF_ARGS, `${baseCommit}...${headCommit}`, "--"], "utf-8");
|
|
179
|
+
const parsedFiles = parseUnifiedDiff(output);
|
|
180
|
+
const withContent = parsedFiles.filter((parsed) => parsed.status !== "deleted" && !parsed.binary).map((parsed) => parsed.path);
|
|
181
|
+
const contents = await readCommitFiles(repoPath, headCommit, withContent);
|
|
182
|
+
const results = parsedFiles.map((parsed) => {
|
|
183
|
+
const content = contents.get(parsed.path);
|
|
184
|
+
return toEntry(parsed, content === undefined ? null : snapshotText(content));
|
|
185
|
+
});
|
|
186
|
+
return { baseCommit, headCommit, results };
|
|
187
|
+
}
|
|
188
|
+
async function workingTreeSnapshot(repoPath) {
|
|
189
|
+
const baseCommit = await resolveCommit(repoPath, "HEAD", "base");
|
|
190
|
+
const output = await runGit(repoPath, [...DIFF_ARGS, "HEAD", "--"], "utf-8");
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const parsed of parseUnifiedDiff(output)) {
|
|
193
|
+
const content = parsed.status !== "deleted" && !parsed.binary ? snapshotText(readWorkingFile(repoPath, parsed.path)) : null;
|
|
194
|
+
results.push(toEntry(parsed, content));
|
|
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);
|
|
200
|
+
for (const filePath of untracked) {
|
|
201
|
+
const raw = readWorkingFile(repoPath, filePath);
|
|
202
|
+
const binary = looksBinary(raw);
|
|
203
|
+
const content = binary ? null : snapshotText(raw);
|
|
204
|
+
const lines = binary ? [] : splitLines(raw.toString("utf-8"));
|
|
205
|
+
const hunks = additionHunk(lines);
|
|
206
|
+
results.push(toEntry({ path: filePath, oldPath: null, status: "added", binary, hunks, additions: lines.length, deletions: 0 }, content));
|
|
207
|
+
}
|
|
208
|
+
return { baseCommit, headCommit: null, results };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Calcule le snapshot d'une analyse avec git.
|
|
212
|
+
* - `branch` : diff `base...head` (head vaut `HEAD` par défaut), contenu lu dans le commit de tête ;
|
|
213
|
+
* - `working_tree` : diff de HEAD vers la copie de travail, index compris, fichiers non suivis
|
|
214
|
+
* présentés comme ajoutés, contenu lu sur disque.
|
|
215
|
+
* Les fichiers sont triés par chemin ; `reviewed` vaut false partout.
|
|
216
|
+
*/
|
|
217
|
+
export async function computeSnapshot(request) {
|
|
218
|
+
const { repoPath, mode } = request;
|
|
219
|
+
await assertRepoRoot(repoPath);
|
|
220
|
+
let base;
|
|
221
|
+
let head;
|
|
222
|
+
let computed;
|
|
223
|
+
if (mode === "branch") {
|
|
224
|
+
const requestedBase = request.base?.trim();
|
|
225
|
+
if (!requestedBase)
|
|
226
|
+
throw new GitError(`"base" is required in branch mode: give the ref the feature branched from.`);
|
|
227
|
+
base = requestedBase;
|
|
228
|
+
head = request.head?.trim() || DEFAULT_HEAD_REF;
|
|
229
|
+
computed = await branchSnapshot(repoPath, base, head);
|
|
230
|
+
}
|
|
231
|
+
else if (mode === "working_tree") {
|
|
232
|
+
if (request.head != null && request.head.trim() !== "") {
|
|
233
|
+
throw new GitError(`"head" is not accepted in working_tree mode: the working tree is compared against HEAD.`);
|
|
234
|
+
}
|
|
235
|
+
if (request.base != null && request.base.trim() !== "" && request.base.trim() !== "HEAD") {
|
|
236
|
+
throw new GitError(`"base" is not accepted in working_tree mode: the working tree is always compared against HEAD.`);
|
|
237
|
+
}
|
|
238
|
+
base = "HEAD";
|
|
239
|
+
head = null;
|
|
240
|
+
computed = await workingTreeSnapshot(repoPath);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
throw new GitError(`Unknown mode "${String(mode)}": use "branch" or "working_tree".`);
|
|
244
|
+
}
|
|
245
|
+
computed.results.sort((a, b) => (a.entry.path < b.entry.path ? -1 : a.entry.path > b.entry.path ? 1 : 0));
|
|
246
|
+
return {
|
|
247
|
+
repoPath,
|
|
248
|
+
mode,
|
|
249
|
+
base,
|
|
250
|
+
head,
|
|
251
|
+
baseCommit: computed.baseCommit,
|
|
252
|
+
headCommit: computed.headCommit,
|
|
253
|
+
snapshotAt: new Date().toISOString(),
|
|
254
|
+
files: computed.results.map((result) => result.entry),
|
|
255
|
+
diff: computed.results.map((result) => result.diff),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Suite aléatoire de caractères [a-z0-9]. */
|
|
2
|
+
export declare function randomToken(length: number): string;
|
|
3
|
+
/** "Refonte de l'Écran" -> "refonte-de-l-ecran" ; "analysis" quand rien ne reste. */
|
|
4
|
+
export declare function slugify(value: string): string;
|
|
5
|
+
/** Identifiant d'analyse : slug du titre suivi d'un identifiant court, par ex. "oauth-refresh-k3x9q2". */
|
|
6
|
+
export declare function analysisId(title: string): string;
|
|
7
|
+
/** Préfixe des identifiants générés, par type d'élément. */
|
|
8
|
+
export declare const ITEM_ID_PREFIXES: {
|
|
9
|
+
readonly finding: "f";
|
|
10
|
+
readonly note: "n";
|
|
11
|
+
readonly diagram: "d";
|
|
12
|
+
readonly node: "nd";
|
|
13
|
+
};
|
|
14
|
+
export type ItemKind = keyof typeof ITEM_ID_PREFIXES;
|
|
15
|
+
/** Identifiant court d'élément, par ex. "f_a1b2c3", absent de `taken`. */
|
|
16
|
+
export declare function itemId(kind: ItemKind, taken: ReadonlySet<string>): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3
|
+
/** Suite aléatoire de caractères [a-z0-9]. */
|
|
4
|
+
export function randomToken(length) {
|
|
5
|
+
const bytes = crypto.randomBytes(length);
|
|
6
|
+
let out = "";
|
|
7
|
+
for (const byte of bytes)
|
|
8
|
+
out += ALPHABET[byte % ALPHABET.length];
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/** "Refonte de l'Écran" -> "refonte-de-l-ecran" ; "analysis" quand rien ne reste. */
|
|
12
|
+
export function slugify(value) {
|
|
13
|
+
const slug = value
|
|
14
|
+
.normalize("NFD")
|
|
15
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-+|-+$/g, "")
|
|
19
|
+
.slice(0, 48)
|
|
20
|
+
.replace(/-+$/g, "");
|
|
21
|
+
return slug || "analysis";
|
|
22
|
+
}
|
|
23
|
+
/** Identifiant d'analyse : slug du titre suivi d'un identifiant court, par ex. "oauth-refresh-k3x9q2". */
|
|
24
|
+
export function analysisId(title) {
|
|
25
|
+
return `${slugify(title)}-${randomToken(6)}`;
|
|
26
|
+
}
|
|
27
|
+
/** Préfixe des identifiants générés, par type d'élément. */
|
|
28
|
+
export const ITEM_ID_PREFIXES = {
|
|
29
|
+
finding: "f",
|
|
30
|
+
note: "n",
|
|
31
|
+
diagram: "d",
|
|
32
|
+
node: "nd",
|
|
33
|
+
};
|
|
34
|
+
/** Identifiant court d'élément, par ex. "f_a1b2c3", absent de `taken`. */
|
|
35
|
+
export function itemId(kind, taken) {
|
|
36
|
+
for (;;) {
|
|
37
|
+
const candidate = `${ITEM_ID_PREFIXES[kind]}_${randomToken(6)}`;
|
|
38
|
+
if (!taken.has(candidate))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Analysis } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Liste les violations des règles de cohérence d'une analyse :
|
|
4
|
+
* - refs cohérentes avec le mode : `branch` a une ref et un commit de tête ; `working_tree`
|
|
5
|
+
* compare à HEAD, sans ref ni commit de tête ;
|
|
6
|
+
* - identifiants uniques (fichiers, constats, remarques, schémas, nœuds d'un schéma) ;
|
|
7
|
+
* - un constat `issue` a un emplacement, un `requirement_gap` peut ne pas en avoir ;
|
|
8
|
+
* - un emplacement de constat porte un `anchorText` ; sans emplacement, ni `anchorText` ni `suggestion` ;
|
|
9
|
+
* - un constat `open` vise un fichier de l'analyse dont le contenu est disponible, avec
|
|
10
|
+
* 1 ≤ startLine ≤ endLine ≤ nombre de lignes ; un constat `ignored` ou `outdated` garde
|
|
11
|
+
* l'emplacement qu'il avait, même si le snapshot a changé depuis ;
|
|
12
|
+
* - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
|
|
13
|
+
* déclarée dans `layers`, sans doublon ;
|
|
14
|
+
* - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findInvariantViolations(analysis: Analysis): string[];
|
|
17
|
+
/** Lève une AnalysisError listant toutes les violations, s'il y en a. */
|
|
18
|
+
export declare function assertInvariants(analysis: Analysis): void;
|
|
19
|
+
/**
|
|
20
|
+
* Vérifie qu'une modification ne touche pas aux champs fixés par le snapshot
|
|
21
|
+
* (identité, dépôt, refs, commits, liste et métadonnées des fichiers hors
|
|
22
|
+
* `reviewed`). Seul le recalcul du snapshot les change.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertSnapshotFieldsUnchanged(before: Analysis, after: Analysis): void;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { AnalysisError } from "./errors.js";
|
|
2
|
+
/** Identifiants présents plusieurs fois dans `ids`. */
|
|
3
|
+
function duplicates(ids) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const repeated = new Set();
|
|
6
|
+
for (const id of ids) {
|
|
7
|
+
if (seen.has(id))
|
|
8
|
+
repeated.add(id);
|
|
9
|
+
seen.add(id);
|
|
10
|
+
}
|
|
11
|
+
return [...repeated];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Liste les violations des règles de cohérence d'une analyse :
|
|
15
|
+
* - refs cohérentes avec le mode : `branch` a une ref et un commit de tête ; `working_tree`
|
|
16
|
+
* compare à HEAD, sans ref ni commit de tête ;
|
|
17
|
+
* - identifiants uniques (fichiers, constats, remarques, schémas, nœuds d'un schéma) ;
|
|
18
|
+
* - un constat `issue` a un emplacement, un `requirement_gap` peut ne pas en avoir ;
|
|
19
|
+
* - un emplacement de constat porte un `anchorText` ; sans emplacement, ni `anchorText` ni `suggestion` ;
|
|
20
|
+
* - un constat `open` vise un fichier de l'analyse dont le contenu est disponible, avec
|
|
21
|
+
* 1 ≤ startLine ≤ endLine ≤ nombre de lignes ; un constat `ignored` ou `outdated` garde
|
|
22
|
+
* l'emplacement qu'il avait, même si le snapshot a changé depuis ;
|
|
23
|
+
* - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
|
|
24
|
+
* déclarée dans `layers`, sans doublon ;
|
|
25
|
+
* - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
|
|
26
|
+
*/
|
|
27
|
+
export function findInvariantViolations(analysis) {
|
|
28
|
+
const problems = [];
|
|
29
|
+
const files = new Map(analysis.files.map((file) => [file.path, file]));
|
|
30
|
+
if (analysis.mode === "branch" && (analysis.head === null || analysis.headCommit === null)) {
|
|
31
|
+
problems.push("a branch analysis needs a head ref and a head commit");
|
|
32
|
+
}
|
|
33
|
+
if (analysis.mode === "working_tree" && (analysis.base !== "HEAD" || analysis.head !== null || analysis.headCommit !== null)) {
|
|
34
|
+
problems.push('a working_tree analysis has base "HEAD" and no head ref nor head commit');
|
|
35
|
+
}
|
|
36
|
+
for (const [label, ids] of [
|
|
37
|
+
["file path", analysis.files.map((file) => file.path)],
|
|
38
|
+
["finding id", analysis.findings.map((finding) => finding.id)],
|
|
39
|
+
["note id", analysis.notes.map((note) => note.id)],
|
|
40
|
+
["diagram id", analysis.diagrams.map((diagram) => diagram.id)],
|
|
41
|
+
]) {
|
|
42
|
+
for (const id of duplicates(ids))
|
|
43
|
+
problems.push(`duplicate ${label} "${id}"`);
|
|
44
|
+
}
|
|
45
|
+
for (const finding of analysis.findings) {
|
|
46
|
+
const where = `finding "${finding.id}"`;
|
|
47
|
+
const location = finding.location;
|
|
48
|
+
if (location === null) {
|
|
49
|
+
if (finding.kind === "issue") {
|
|
50
|
+
problems.push(`${where} is an issue and needs a location (only requirement_gap findings may have none)`);
|
|
51
|
+
}
|
|
52
|
+
if (finding.anchorText !== null)
|
|
53
|
+
problems.push(`${where} has anchorText but no location`);
|
|
54
|
+
if (finding.suggestion !== null)
|
|
55
|
+
problems.push(`${where} has a suggestion but no location to replace`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (finding.anchorText === null)
|
|
59
|
+
problems.push(`${where} has a location but no anchorText`);
|
|
60
|
+
if (location.startLine > location.endLine) {
|
|
61
|
+
problems.push(`${where} has startLine ${location.startLine} after endLine ${location.endLine}`);
|
|
62
|
+
}
|
|
63
|
+
if (finding.status !== "open")
|
|
64
|
+
continue;
|
|
65
|
+
const file = files.get(location.path);
|
|
66
|
+
if (!file) {
|
|
67
|
+
problems.push(`${where} targets "${location.path}", which is not a changed file of this analysis`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!file.contentAvailable) {
|
|
71
|
+
problems.push(`${where} targets lines of "${location.path}", whose content is not available`);
|
|
72
|
+
}
|
|
73
|
+
else if (location.endLine > file.lineCount) {
|
|
74
|
+
problems.push(`${where} targets lines ${location.startLine}-${location.endLine} of "${location.path}", ` +
|
|
75
|
+
`which has ${file.lineCount} line(s)`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const diagram of analysis.diagrams) {
|
|
79
|
+
const where = `diagram "${diagram.id}"`;
|
|
80
|
+
const nodeIds = new Set(diagram.nodes.map((node) => node.id));
|
|
81
|
+
for (const id of duplicates(diagram.nodes.map((node) => node.id)))
|
|
82
|
+
problems.push(`${where} has duplicate node id "${id}"`);
|
|
83
|
+
for (const link of diagram.links) {
|
|
84
|
+
for (const end of [link.from, link.to]) {
|
|
85
|
+
if (!nodeIds.has(end))
|
|
86
|
+
problems.push(`${where} has a link ${link.from} -> ${link.to} to unknown node "${end}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (diagram.kind === "layers") {
|
|
90
|
+
for (const layer of duplicates(diagram.layers))
|
|
91
|
+
problems.push(`${where} declares layer "${layer}" twice`);
|
|
92
|
+
const layers = new Set(diagram.layers);
|
|
93
|
+
for (const node of diagram.nodes) {
|
|
94
|
+
if (node.layer === null)
|
|
95
|
+
problems.push(`${where} node "${node.id}" needs a layer (kind is layers)`);
|
|
96
|
+
else if (!layers.has(node.layer)) {
|
|
97
|
+
problems.push(`${where} node "${node.id}" uses layer "${node.layer}", which is not in layers [${diagram.layers.join(", ")}]`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const findingIds = new Set(analysis.findings.map((finding) => finding.id));
|
|
103
|
+
const noteIds = new Set(analysis.notes.map((note) => note.id));
|
|
104
|
+
const review = analysis.review;
|
|
105
|
+
for (const id of review.selectedFindingIds) {
|
|
106
|
+
if (!findingIds.has(id))
|
|
107
|
+
problems.push(`review selects unknown finding "${id}"`);
|
|
108
|
+
}
|
|
109
|
+
for (const id of review.selectedNoteIds) {
|
|
110
|
+
if (!noteIds.has(id))
|
|
111
|
+
problems.push(`review selects unknown note "${id}"`);
|
|
112
|
+
}
|
|
113
|
+
if (review.state === "submitted" && (review.decision === null || review.submittedAt === null)) {
|
|
114
|
+
problems.push("a submitted review needs a decision and a submittedAt date");
|
|
115
|
+
}
|
|
116
|
+
if (review.state === "pending" && review.submittedAt !== null) {
|
|
117
|
+
problems.push("a pending review cannot have a submittedAt date");
|
|
118
|
+
}
|
|
119
|
+
return problems;
|
|
120
|
+
}
|
|
121
|
+
/** Lève une AnalysisError listant toutes les violations, s'il y en a. */
|
|
122
|
+
export function assertInvariants(analysis) {
|
|
123
|
+
const problems = findInvariantViolations(analysis);
|
|
124
|
+
if (problems.length > 0) {
|
|
125
|
+
throw new AnalysisError(`Analysis "${analysis.id}" would become inconsistent: ${problems.join("; ")}.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Champ d'un fichier modifiable hors recalcul du snapshot. */
|
|
129
|
+
const MUTABLE_FILE_FIELDS = new Set(["reviewed"]);
|
|
130
|
+
/**
|
|
131
|
+
* Vérifie qu'une modification ne touche pas aux champs fixés par le snapshot
|
|
132
|
+
* (identité, dépôt, refs, commits, liste et métadonnées des fichiers hors
|
|
133
|
+
* `reviewed`). Seul le recalcul du snapshot les change.
|
|
134
|
+
*/
|
|
135
|
+
export function assertSnapshotFieldsUnchanged(before, after) {
|
|
136
|
+
const changed = [];
|
|
137
|
+
for (const field of ["id", "repoPath", "mode", "base", "head", "baseCommit", "headCommit", "snapshotAt", "createdAt"]) {
|
|
138
|
+
if (before[field] !== after[field])
|
|
139
|
+
changed.push(field);
|
|
140
|
+
}
|
|
141
|
+
if (before.files.length !== after.files.length) {
|
|
142
|
+
changed.push("files");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
before.files.forEach((file, index) => {
|
|
146
|
+
const next = after.files[index];
|
|
147
|
+
for (const key of Object.keys(file)) {
|
|
148
|
+
if (!MUTABLE_FILE_FIELDS.has(key) && file[key] !== next[key])
|
|
149
|
+
changed.push(`files[${index}].${key}`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (changed.length > 0) {
|
|
154
|
+
throw new AnalysisError(`Cannot change ${changed.join(", ")} of analysis "${before.id}": these fields come from the diff snapshot. ` +
|
|
155
|
+
`Use refresh_analysis to recompute the snapshot.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FileEntry } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
import type { DiffSnapshot } from "../../shared/schemas/diff.schema.js";
|
|
3
|
+
export interface LocationRequest {
|
|
4
|
+
path: string;
|
|
5
|
+
startLine: number;
|
|
6
|
+
/** Dernière ligne incluse ; vaut `startLine` quand elle est omise. */
|
|
7
|
+
endLine?: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface ResolvedLocation {
|
|
10
|
+
path: string;
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
/** Texte exact des lignes visées, jointes par `\n`. */
|
|
14
|
+
anchorText: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Vérifie un emplacement (lignes du côté « nouveau ») contre le snapshot et
|
|
18
|
+
* renvoie le texte des lignes visées. Erreur explicite si le fichier n'est pas
|
|
19
|
+
* dans l'analyse, si son contenu n'est pas disponible ou si les lignes sortent
|
|
20
|
+
* du fichier.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveLocation(snapshot: DiffSnapshot, files: FileEntry[], request: LocationRequest): ResolvedLocation;
|
|
23
|
+
/**
|
|
24
|
+
* Vérifie l'emplacement d'une remarque : un fichier de l'analyse, et une ligne
|
|
25
|
+
* du côté « nouveau » quand `line` est fournie.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assertNoteLocation(files: FileEntry[], location: {
|
|
28
|
+
path: string;
|
|
29
|
+
line: number | null;
|
|
30
|
+
}): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { splitLines } from "../../shared/text.js";
|
|
2
|
+
import { AnalysisError } from "./errors.js";
|
|
3
|
+
/** Liste lisible des chemins d'une analyse, pour les messages d'erreur. */
|
|
4
|
+
function describeFiles(files) {
|
|
5
|
+
if (files.length === 0)
|
|
6
|
+
return "the analysis has no changed files";
|
|
7
|
+
return `changed files: ${files.map((file) => file.path).join(", ")}`;
|
|
8
|
+
}
|
|
9
|
+
/** Raison pour laquelle le contenu d'un fichier n'est pas conservé dans le snapshot. */
|
|
10
|
+
function unavailableReason(file) {
|
|
11
|
+
if (file.status === "deleted")
|
|
12
|
+
return "it is deleted";
|
|
13
|
+
if (file.binary)
|
|
14
|
+
return "it is binary";
|
|
15
|
+
return "it is larger than the snapshot size limit";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Vérifie un emplacement (lignes du côté « nouveau ») contre le snapshot et
|
|
19
|
+
* renvoie le texte des lignes visées. Erreur explicite si le fichier n'est pas
|
|
20
|
+
* dans l'analyse, si son contenu n'est pas disponible ou si les lignes sortent
|
|
21
|
+
* du fichier.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveLocation(snapshot, files, request) {
|
|
24
|
+
const endLine = request.endLine ?? request.startLine;
|
|
25
|
+
const { path, startLine } = request;
|
|
26
|
+
const file = files.find((entry) => entry.path === path);
|
|
27
|
+
if (!file) {
|
|
28
|
+
throw new AnalysisError(`File "${path}" is not part of this analysis (${describeFiles(files)}).`);
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
|
|
31
|
+
throw new AnalysisError(`Invalid line range ${startLine}-${endLine} for "${path}": lines are 1-based integers and end_line must be >= start_line.`);
|
|
32
|
+
}
|
|
33
|
+
const diff = snapshot.files.find((entry) => entry.path === path);
|
|
34
|
+
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
|
+
}
|
|
38
|
+
const lines = splitLines(diff.newContent);
|
|
39
|
+
if (endLine > lines.length) {
|
|
40
|
+
throw new AnalysisError(`Lines ${startLine}-${endLine} are out of range for "${path}", which has ${lines.length} line(s) on the new side. ` +
|
|
41
|
+
`Use get_diff to read the new-side line numbers.`);
|
|
42
|
+
}
|
|
43
|
+
return { path, startLine, endLine, anchorText: lines.slice(startLine - 1, endLine).join("\n") };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Vérifie l'emplacement d'une remarque : un fichier de l'analyse, et une ligne
|
|
47
|
+
* du côté « nouveau » quand `line` est fournie.
|
|
48
|
+
*/
|
|
49
|
+
export function assertNoteLocation(files, location) {
|
|
50
|
+
const file = files.find((entry) => entry.path === location.path);
|
|
51
|
+
if (!file) {
|
|
52
|
+
throw new AnalysisError(`File "${location.path}" is not part of this analysis (${describeFiles(files)}).`);
|
|
53
|
+
}
|
|
54
|
+
if (location.line === null)
|
|
55
|
+
return;
|
|
56
|
+
if (!Number.isInteger(location.line) || location.line < 1) {
|
|
57
|
+
throw new AnalysisError(`Invalid line ${location.line} for "${location.path}": lines are 1-based integers.`);
|
|
58
|
+
}
|
|
59
|
+
if (!file.contentAvailable) {
|
|
60
|
+
throw new AnalysisError(`Cannot attach a note to a line of "${location.path}": its content is not available because ${unavailableReason(file)}. ` +
|
|
61
|
+
`Attach the note to the whole file instead.`);
|
|
62
|
+
}
|
|
63
|
+
if (location.line > file.lineCount) {
|
|
64
|
+
throw new AnalysisError(`Line ${location.line} is out of range for "${location.path}", which has ${file.lineCount} line(s) on the new side.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Répertoire des logs : MCP_LOG_DIR s'il est renseigné, sinon <dataDir>/logs.
|
|
3
|
+
* Lu depuis process.env au démarrage, avant le chargement du .env du package :
|
|
4
|
+
* la variable vient donc de la déclaration du serveur dans le client MCP.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveLogDir(rootDir: string): string;
|