@imenam/mcp-feature-analyzer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +295 -0
  3. package/dist/shared/diagram-geometry.d.ts +45 -0
  4. package/dist/shared/diagram-geometry.js +120 -0
  5. package/dist/shared/diagram-layout.d.ts +98 -0
  6. package/dist/shared/diagram-layout.js +1156 -0
  7. package/dist/shared/diagram-quality.d.ts +30 -0
  8. package/dist/shared/diagram-quality.js +26 -0
  9. package/dist/shared/labels.d.ts +31 -0
  10. package/dist/shared/labels.js +53 -0
  11. package/dist/shared/mindmap-tree.d.ts +28 -0
  12. package/dist/shared/mindmap-tree.js +62 -0
  13. package/dist/shared/prompt.d.ts +17 -0
  14. package/dist/shared/prompt.js +97 -0
  15. package/dist/shared/review-state.d.ts +10 -0
  16. package/dist/shared/review-state.js +17 -0
  17. package/dist/shared/schemas/analysis.schema.d.ts +372 -0
  18. package/dist/shared/schemas/analysis.schema.js +152 -0
  19. package/dist/shared/schemas/api.schema.d.ts +92 -0
  20. package/dist/shared/schemas/api.schema.js +46 -0
  21. package/dist/shared/schemas/diff.schema.d.ts +91 -0
  22. package/dist/shared/schemas/diff.schema.js +37 -0
  23. package/dist/shared/schemas/ipc.schema.d.ts +29 -0
  24. package/dist/shared/schemas/ipc.schema.js +30 -0
  25. package/dist/shared/severity.d.ts +11 -0
  26. package/dist/shared/severity.js +17 -0
  27. package/dist/shared/text.d.ts +12 -0
  28. package/dist/shared/text.js +20 -0
  29. package/dist/src/cli/setup-mcp.d.ts +24 -0
  30. package/dist/src/cli/setup-mcp.js +145 -0
  31. package/dist/src/core/analysis-store.d.ts +95 -0
  32. package/dist/src/core/analysis-store.js +387 -0
  33. package/dist/src/core/data-dir.d.ts +5 -0
  34. package/dist/src/core/data-dir.js +11 -0
  35. package/dist/src/core/diff-parser.d.ts +22 -0
  36. package/dist/src/core/diff-parser.js +225 -0
  37. package/dist/src/core/errors.d.ts +16 -0
  38. package/dist/src/core/errors.js +28 -0
  39. package/dist/src/core/git.d.ts +35 -0
  40. package/dist/src/core/git.js +257 -0
  41. package/dist/src/core/ids.d.ts +16 -0
  42. package/dist/src/core/ids.js +41 -0
  43. package/dist/src/core/invariants.d.ts +24 -0
  44. package/dist/src/core/invariants.js +157 -0
  45. package/dist/src/core/locations.d.ts +30 -0
  46. package/dist/src/core/locations.js +66 -0
  47. package/dist/src/core/log-dir.d.ts +6 -0
  48. package/dist/src/core/log-dir.js +13 -0
  49. package/dist/src/core/migrate.d.ts +24 -0
  50. package/dist/src/core/migrate.js +53 -0
  51. package/dist/src/core/refresh.d.ts +30 -0
  52. package/dist/src/core/refresh.js +113 -0
  53. package/dist/src/core/request-guard.d.ts +32 -0
  54. package/dist/src/core/request-guard.js +72 -0
  55. package/dist/src/core/review-actions.d.ts +27 -0
  56. package/dist/src/core/review-actions.js +99 -0
  57. package/dist/src/gui-worker.d.ts +1 -0
  58. package/dist/src/gui-worker.js +401 -0
  59. package/dist/src/index.d.ts +2 -0
  60. package/dist/src/index.js +658 -0
  61. package/dist/src/tools/add-findings.d.ts +9 -0
  62. package/dist/src/tools/add-findings.js +106 -0
  63. package/dist/src/tools/create-analysis.d.ts +4 -0
  64. package/dist/src/tools/create-analysis.js +36 -0
  65. package/dist/src/tools/delete-analysis.d.ts +4 -0
  66. package/dist/src/tools/delete-analysis.js +11 -0
  67. package/dist/src/tools/delete-diagram.d.ts +4 -0
  68. package/dist/src/tools/delete-diagram.js +22 -0
  69. package/dist/src/tools/delete-findings.d.ts +7 -0
  70. package/dist/src/tools/delete-findings.js +27 -0
  71. package/dist/src/tools/format.d.ts +27 -0
  72. package/dist/src/tools/format.js +92 -0
  73. package/dist/src/tools/get-analysis.d.ts +4 -0
  74. package/dist/src/tools/get-analysis.js +87 -0
  75. package/dist/src/tools/get-diff.d.ts +4 -0
  76. package/dist/src/tools/get-diff.js +67 -0
  77. package/dist/src/tools/get-review-feedback.d.ts +4 -0
  78. package/dist/src/tools/get-review-feedback.js +71 -0
  79. package/dist/src/tools/inputs.d.ts +14 -0
  80. package/dist/src/tools/inputs.js +40 -0
  81. package/dist/src/tools/list-analyses.d.ts +8 -0
  82. package/dist/src/tools/list-analyses.js +71 -0
  83. package/dist/src/tools/refresh-analysis.d.ts +7 -0
  84. package/dist/src/tools/refresh-analysis.js +39 -0
  85. package/dist/src/tools/set-diagram.d.ts +16 -0
  86. package/dist/src/tools/set-diagram.js +207 -0
  87. package/dist/src/tools/types.d.ts +52 -0
  88. package/dist/src/tools/types.js +133 -0
  89. package/dist/src/tools/update-analysis.d.ts +4 -0
  90. package/dist/src/tools/update-analysis.js +121 -0
  91. package/dist/src/tools/update-finding.d.ts +8 -0
  92. package/dist/src/tools/update-finding.js +91 -0
  93. package/dist/src/version.d.ts +1 -0
  94. package/dist/src/version.js +2 -0
  95. package/gui/dist/assets/index-CU9pNYDp.js +72 -0
  96. package/gui/dist/assets/index-Du5eL78F.css +1 -0
  97. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  98. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  99. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  100. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  101. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  102. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  103. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  104. package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  105. package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  106. package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  107. package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  108. package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  109. package/gui/dist/index.html +13 -0
  110. package/package.json +56 -0
  111. package/scripts/generate-version.js +20 -0
@@ -0,0 +1,40 @@
1
+ import { ITEM_ID_PATTERN } from "../../shared/schemas/analysis.schema.js";
2
+ import { has, nullableString, optionalPositiveInt } from "./types.js";
3
+ /**
4
+ * Lit `path`, `start_line` et `end_line`. `path` et `start_line` vont ensemble ;
5
+ * `end_line` vaut `start_line` quand il est omis. `path: null` retire l'emplacement.
6
+ */
7
+ export function readLocation(args, prefix = "") {
8
+ const path = nullableString(args, "path", prefix);
9
+ const startLine = optionalPositiveInt(args, "start_line", prefix);
10
+ const endLine = optionalPositiveInt(args, "end_line", prefix);
11
+ if (path === null) {
12
+ if (startLine !== undefined || endLine !== undefined) {
13
+ throw new Error(`"${prefix}path" is null (location removed), so "start_line" and "end_line" cannot be set.`);
14
+ }
15
+ return null;
16
+ }
17
+ if (path === undefined) {
18
+ if (startLine !== undefined || endLine !== undefined) {
19
+ throw new Error(`"${prefix}start_line" and "${prefix}end_line" need "${prefix}path": give the file they refer to.`);
20
+ }
21
+ return undefined;
22
+ }
23
+ if (startLine === undefined) {
24
+ throw new Error(`"${prefix}start_line" is required with "${prefix}path": give the first new-side line (see get_diff).`);
25
+ }
26
+ if (endLine !== undefined && endLine < startLine) {
27
+ throw new Error(`"${prefix}end_line" (${endLine}) must be >= "${prefix}start_line" (${startLine}).`);
28
+ }
29
+ return { path, startLine, endLine };
30
+ }
31
+ /** Lit un identifiant d'élément facultatif fourni par l'agent. */
32
+ export function readItemId(args, field, prefix = "") {
33
+ if (!has(args, field))
34
+ return undefined;
35
+ const value = args[field];
36
+ if (typeof value !== "string" || !ITEM_ID_PATTERN.test(value)) {
37
+ throw new Error(`"${prefix}${field}" must contain only letters, digits, '_' or '-' (1 to 64 characters).`);
38
+ }
39
+ return value;
40
+ }
@@ -0,0 +1,8 @@
1
+ import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type Args, type ToolResult } from "./types.js";
3
+ /**
4
+ * Liste les analyses regroupées par projet (projet le plus récemment modifié en
5
+ * premier, puis analyses de la plus récente à la plus ancienne), éventuellement
6
+ * limitées à un projet, et les fichiers illisibles.
7
+ */
8
+ export declare function listAnalyses(store: AnalysisStore, args: Args): ToolResult;
@@ -0,0 +1,71 @@
1
+ import { REVIEW_PROGRESS_STATES } from "../../shared/schemas/analysis.schema.js";
2
+ import { progressLabel, progressStateLabel, refsLabel, severityCountsLabel } from "./format.js";
3
+ import { optionalString, rejectUnknownFields, textResult } from "./types.js";
4
+ /** Analyses regroupées par projet, les projets triés par leur analyse la plus récente. */
5
+ function groupByProject(analyses) {
6
+ const groups = new Map();
7
+ for (const summary of analyses) {
8
+ const group = groups.get(summary.project);
9
+ if (group)
10
+ group.push(summary);
11
+ else
12
+ groups.set(summary.project, [summary]);
13
+ }
14
+ return groups;
15
+ }
16
+ /** `2 analysis(es): 1 in progress, 1 submitted`. */
17
+ function projectCounts(analyses) {
18
+ const counts = REVIEW_PROGRESS_STATES.map((state) => [state, analyses.filter((summary) => summary.progress.state === state).length])
19
+ .filter(([, count]) => count > 0)
20
+ .map(([state, count]) => `${count} ${progressStateLabel(state)}`);
21
+ return `${analyses.length} analysis(es): ${counts.join(", ")}`;
22
+ }
23
+ function analysisLines(summary) {
24
+ const feedback = summary.progress.state === "submitted" ? " — read it with get_review_feedback" : "";
25
+ return [
26
+ `- ${summary.id} — "${summary.title}"`,
27
+ ` ${refsLabel(summary)} | files reviewed ${summary.reviewedCount}/${summary.fileCount} | ` +
28
+ `open findings: ${severityCountsLabel(summary.openFindings)} | diagrams: ${summary.diagramCount}`,
29
+ ` review: ${progressLabel(summary.progress)}${feedback} | updated ${summary.updatedAt}`,
30
+ ];
31
+ }
32
+ /**
33
+ * Liste les analyses regroupées par projet (projet le plus récemment modifié en
34
+ * premier, puis analyses de la plus récente à la plus ancienne), éventuellement
35
+ * limitées à un projet, et les fichiers illisibles.
36
+ */
37
+ export function listAnalyses(store, args) {
38
+ rejectUnknownFields(args, ["project"]);
39
+ const project = optionalString(args, "project");
40
+ const { analyses, unreadable } = store.list();
41
+ const groups = groupByProject(analyses);
42
+ const lines = [];
43
+ if (project !== undefined && !groups.has(project)) {
44
+ const known = [...groups.keys()];
45
+ throw new Error(known.length === 0
46
+ ? `Unknown project "${project}": there are no analyses yet. Create one with create_analysis.`
47
+ : `Unknown project "${project}". Existing projects: ${known.map((name) => `"${name}"`).join(", ")}. ` +
48
+ `Call list_analyses without project to see them all, or create_analysis to start a new project.`);
49
+ }
50
+ if (analyses.length === 0) {
51
+ lines.push("No analyses yet. Create one with create_analysis once the feature is developed: it freezes the diff of a branch or of the working tree for human review.");
52
+ }
53
+ else {
54
+ const shown = project === undefined ? [...groups] : [[project, groups.get(project)]];
55
+ const total = shown.reduce((sum, [, list]) => sum + list.length, 0);
56
+ lines.push(project === undefined
57
+ ? `${total} analysis(es) in ${groups.size} project(s), most recently updated first:`
58
+ : `${total} analysis(es) in project "${project}", most recently updated first:`);
59
+ for (const [name, list] of shown) {
60
+ lines.push("", `## Project "${name}" (${projectCounts(list)})`);
61
+ for (const summary of list)
62
+ lines.push(...analysisLines(summary));
63
+ }
64
+ lines.push("", "To continue the review of a feature, reuse its analysis: update_analysis to rewrite the overview and summary, " +
65
+ "refresh_analysis after changing the code. Create a new analysis only for another feature or another diff.");
66
+ }
67
+ if (unreadable.length > 0) {
68
+ lines.push("", `${unreadable.length} analysis file(s) could not be read. Fix the file or remove it with delete_analysis:`, ...unreadable.map((entry) => `- ${entry.id}: ${entry.error}`));
69
+ }
70
+ return textResult(lines.join("\n"));
71
+ }
@@ -0,0 +1,7 @@
1
+ import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type Args, type MutationResult } from "./types.js";
3
+ /**
4
+ * Recalcule le snapshot avec le dépôt, le mode et les refs de l'analyse ; la
5
+ * revue repart à l'état `pending` sans décision, sélection ni prompt.
6
+ */
7
+ export declare function refreshAnalysis(store: AnalysisStore, args: Args): Promise<MutationResult>;
@@ -0,0 +1,39 @@
1
+ import { computeSnapshot } from "../core/git.js";
2
+ import { snapshotLabel } from "./format.js";
3
+ import { rejectUnknownFields, requireString, textResult } from "./types.js";
4
+ /**
5
+ * Recalcule le snapshot avec le dépôt, le mode et les refs de l'analyse ; la
6
+ * revue repart à l'état `pending` sans décision, sélection ni prompt.
7
+ */
8
+ export async function refreshAnalysis(store, args) {
9
+ rejectUnknownFields(args, ["analysis_id"]);
10
+ const id = requireString(args, "analysis_id");
11
+ const current = store.get(id);
12
+ const computed = await computeSnapshot({
13
+ repoPath: current.repoPath,
14
+ mode: current.mode,
15
+ base: current.base,
16
+ head: current.head,
17
+ });
18
+ const { analysis, stats, previousReview } = store.replaceSnapshot(id, "agent", computed);
19
+ const outdated = analysis.findings.filter((finding) => finding.status === "outdated");
20
+ const lines = [
21
+ `Refreshed analysis "${analysis.title}" (${analysis.id}).`,
22
+ `Diff: ${snapshotLabel(analysis)}`,
23
+ `Files: ${stats.fileCount} (${stats.filesAdded} new in the diff, ${stats.filesRemoved} no longer in it).`,
24
+ `Reviewed files: ${stats.reviewedKept} kept (diff unchanged), ${stats.reviewedReset} reset to unreviewed (diff changed).`,
25
+ `Findings: ${stats.findingsOutdated} became outdated (their lines changed or left the diff), ` +
26
+ `${stats.findingsRestored} back to open (their lines match again); ${outdated.length} outdated in total.`,
27
+ ];
28
+ if (previousReview.state === "submitted") {
29
+ lines.push(`Review: the previous feedback (decision ${previousReview.decision}, submitted at ${previousReview.submittedAt}) was discarded; the review is pending again.`);
30
+ }
31
+ else {
32
+ lines.push("Review: pending; the previous feedback, decision and selection in progress were discarded.");
33
+ }
34
+ lines.push("", "Next steps:", "- Re-read the changed files with get_diff.", "- For each outdated finding: re-anchor it with update_finding (a new location reopens it), or remove it with delete_findings if it is solved.", "- Ask the user to review the analysis again in the GUI.");
35
+ if (outdated.length > 0) {
36
+ lines.push("", "Outdated findings:", ...outdated.map((finding) => ` ${finding.id} [${finding.severity}] ${finding.title}`));
37
+ }
38
+ return { result: textResult(lines.join("\n")), analysisId: analysis.id };
39
+ }
@@ -0,0 +1,16 @@
1
+ import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type NodeShape, type NodeStatus } from "../../shared/schemas/analysis.schema.js";
3
+ import { type Args, type MutationResult } from "./types.js";
4
+ export declare const NODE_INPUT_FIELDS: readonly ["id", "label", "shape", "status", "layer", "detail", "path", "line"];
5
+ export declare const LINK_INPUT_FIELDS: readonly ["from", "to", "label"];
6
+ /** Forme d'un nœud quand l'agent n'en donne pas. */
7
+ export declare const DEFAULT_NODE_SHAPE: NodeShape;
8
+ /** Statut d'un nœud quand l'agent n'en donne pas. */
9
+ export declare const DEFAULT_NODE_STATUS: NodeStatus;
10
+ /**
11
+ * Crée ou remplace entièrement un schéma. Nœuds, couches et liens sont validés
12
+ * ensemble, une carte mentale doit former un arbre ; toutes les erreurs sont
13
+ * renvoyées d'un coup et rien n'est écrit. Le résultat rend compte des
14
+ * croisements du tracé, sans refuser l'écriture.
15
+ */
16
+ export declare function setDiagram(store: AnalysisStore, args: Args): MutationResult;
@@ -0,0 +1,207 @@
1
+ import { AnalysisError } from "../core/errors.js";
2
+ import { itemId } from "../core/ids.js";
3
+ import { DIAGRAM_KINDS, NODE_SHAPES, NODE_STATUSES, } from "../../shared/schemas/analysis.schema.js";
4
+ import { layoutDiagram } from "../../shared/diagram-layout.js";
5
+ import { diagramQuality } from "../../shared/diagram-quality.js";
6
+ import { mindmapProblems } from "../../shared/mindmap-tree.js";
7
+ import { readItemId } from "./inputs.js";
8
+ import { has, nullableString, optionalEnum, optionalPositiveInt, optionalString, rejectUnknownFields, requireArray, requireEnum, requireObject, requireString, requireStringArray, textResult, } from "./types.js";
9
+ export const NODE_INPUT_FIELDS = ["id", "label", "shape", "status", "layer", "detail", "path", "line"];
10
+ export const LINK_INPUT_FIELDS = ["from", "to", "label"];
11
+ /** Forme d'un nœud quand l'agent n'en donne pas. */
12
+ export const DEFAULT_NODE_SHAPE = "box";
13
+ /** Statut d'un nœud quand l'agent n'en donne pas. */
14
+ export const DEFAULT_NODE_STATUS = "existing";
15
+ /**
16
+ * Vérifie l'emplacement d'un nœud : un fichier modifié de l'analyse et, quand
17
+ * `line` est fournie, une ligne du côté « nouveau » de ce fichier.
18
+ */
19
+ function assertNodeLocation(files, location) {
20
+ const file = files.find((entry) => entry.path === location.path);
21
+ if (!file) {
22
+ throw new Error(`"${location.path}" is not a changed file of this analysis, so the GUI cannot open it. ` +
23
+ `Omit path for code outside the diff and name the file in detail instead.`);
24
+ }
25
+ if (location.line === null)
26
+ return;
27
+ if (!file.contentAvailable) {
28
+ throw new Error(`"${location.path}" has no new-side content (deleted, binary or too large): omit line and keep path only.`);
29
+ }
30
+ if (location.line > file.lineCount) {
31
+ throw new Error(`line ${location.line} is out of range for "${location.path}", which has ${file.lineCount} line(s) on the new side (see get_diff).`);
32
+ }
33
+ }
34
+ /** Nombre de nœuds au-delà duquel un schéma devient difficile à lire. */
35
+ const MAX_READABLE_NODES = 12;
36
+ /** Description anglaise d'un problème d'arborescence d'une carte mentale. */
37
+ function mindmapProblemText(problem) {
38
+ const list = (ids) => ids.map((id) => `"${id}"`).join(", ");
39
+ switch (problem.kind) {
40
+ case "self_loop":
41
+ return `node "${problem.node}" links to itself.`;
42
+ case "no_root":
43
+ return "every node has an incoming link, so there is no root: the central subject must have none.";
44
+ case "several_roots":
45
+ return `${problem.roots.length} nodes have no incoming link (${list(problem.roots)}); keep one root and link the others below it.`;
46
+ case "several_parents":
47
+ return `node "${problem.node}" has ${problem.parents.length} parents (${list(problem.parents)}); keep a single link into it.`;
48
+ case "cycle":
49
+ return `the links form a cycle ${problem.nodes.join(" -> ")}.`;
50
+ case "unreachable":
51
+ return `node(s) ${list(problem.nodes)} cannot be reached from the root "${problem.root}".`;
52
+ }
53
+ }
54
+ function linkLabel(ref) {
55
+ return `links[${ref.index}] ${ref.from} -> ${ref.to}`;
56
+ }
57
+ /** Compte rendu du contrôle de tracé et conseils pour supprimer les croisements. */
58
+ function qualityLines(quality, kind, nodeCount) {
59
+ const lines = [];
60
+ if (quality.crossings.length === 0 && quality.nodeOverlaps.length === 0) {
61
+ lines.push("Layout check: No crossing.");
62
+ }
63
+ else {
64
+ lines.push(`Layout check: ${quality.crossings.length} link crossing(s), ${quality.nodeOverlaps.length} link(s) through a node. ` +
65
+ `The diagram is saved, but the reviewer expects none; fix it and send it again:`, ...quality.crossings.map((crossing) => `- ${linkLabel(crossing.a)} crosses ${linkLabel(crossing.b)}`), ...quality.nodeOverlaps.map((overlap) => `- ${linkLabel(overlap.link)} passes through node "${overlap.node}"`), "How to fix:", "- Reorder the nodes: the declaration order is the initial order of each rank or column, so declare them in reading order, " +
66
+ "with the nodes that link to each other next to each other.", "- Split the diagram: one subject per diagram, 5 to 12 nodes each.");
67
+ if (kind === "flow") {
68
+ lines.push("- Drop shortcut links that skip steps and loops back to earlier steps, or show dependencies as a layers diagram instead.");
69
+ }
70
+ else if (kind === "layers") {
71
+ lines.push("- Order the layers so links run between neighbouring columns, move a node to the layer it belongs to, " +
72
+ "and avoid links between nodes of the same column that are not adjacent.");
73
+ }
74
+ }
75
+ if (nodeCount > MAX_READABLE_NODES) {
76
+ lines.push(`Note: ${nodeCount} nodes; keep a diagram between 5 and ${MAX_READABLE_NODES} nodes and split a bigger picture into several diagrams.`);
77
+ }
78
+ return lines;
79
+ }
80
+ /**
81
+ * Crée ou remplace entièrement un schéma. Nœuds, couches et liens sont validés
82
+ * ensemble, une carte mentale doit former un arbre ; toutes les erreurs sont
83
+ * renvoyées d'un coup et rien n'est écrit. Le résultat rend compte des
84
+ * croisements du tracé, sans refuser l'écriture.
85
+ */
86
+ export function setDiagram(store, args) {
87
+ rejectUnknownFields(args, ["analysis_id", "diagram_id", "title", "kind", "layers", "nodes", "links"]);
88
+ const id = requireString(args, "analysis_id");
89
+ const requestedId = readItemId(args, "diagram_id");
90
+ const title = requireString(args, "title");
91
+ const kind = requireEnum(args, "kind", DIAGRAM_KINDS);
92
+ const nodeItems = requireArray(args, "nodes", 1);
93
+ const linkItems = has(args, "links") ? requireArray(args, "links") : [];
94
+ let layers = [];
95
+ if (kind === "layers") {
96
+ if (!has(args, "layers")) {
97
+ throw new Error(`"layers" is required when kind is layers: list the column names from left to right.`);
98
+ }
99
+ layers = requireStringArray(args, "layers", 1);
100
+ const repeated = layers.filter((layer, index) => layers.indexOf(layer) !== index);
101
+ if (repeated.length > 0)
102
+ throw new Error(`"layers" lists ${[...new Set(repeated)].join(", ")} more than once.`);
103
+ }
104
+ else if (has(args, "layers")) {
105
+ throw new Error(`"layers" is only used when kind is layers (kind is ${kind}): remove it.`);
106
+ }
107
+ const analysis = store.get(id);
108
+ const errors = [];
109
+ const nodes = [];
110
+ const nodeIds = new Set();
111
+ nodeItems.forEach((raw, index) => {
112
+ const where = `nodes[${index}]`;
113
+ try {
114
+ const item = requireObject(raw, where);
115
+ rejectUnknownFields(item, NODE_INPUT_FIELDS, `${where}.`);
116
+ const nodeId = readItemId(item, "id", `${where}.`);
117
+ if (nodeId === undefined)
118
+ throw new Error(`"${where}.id" is required: links refer to nodes by id.`);
119
+ if (nodeIds.has(nodeId))
120
+ throw new Error(`node id "${nodeId}" appears twice.`);
121
+ nodeIds.add(nodeId);
122
+ const label = requireString(item, "label", `${where}.`);
123
+ const shape = optionalEnum(item, "shape", NODE_SHAPES, `${where}.`) ?? DEFAULT_NODE_SHAPE;
124
+ const status = optionalEnum(item, "status", NODE_STATUSES, `${where}.`) ?? DEFAULT_NODE_STATUS;
125
+ const detail = nullableString(item, "detail", `${where}.`) ?? null;
126
+ const layer = optionalString(item, "layer", `${where}.`) ?? null;
127
+ if (kind === "layers") {
128
+ if (layer === null)
129
+ throw new Error(`"${where}.layer" is required when kind is layers: use one of ${layers.join(", ")}.`);
130
+ if (!layers.includes(layer)) {
131
+ throw new Error(`"${where}.layer" is "${layer}", which is not declared in layers (${layers.join(", ")}).`);
132
+ }
133
+ }
134
+ else if (layer !== null) {
135
+ throw new Error(`"${where}.layer" is only used when kind is layers (kind is ${kind}): remove it.`);
136
+ }
137
+ const path = optionalString(item, "path", `${where}.`);
138
+ const line = optionalPositiveInt(item, "line", `${where}.`);
139
+ if (path === undefined && line !== undefined) {
140
+ throw new Error(`"${where}.line" needs "${where}.path": give the file it refers to.`);
141
+ }
142
+ const location = path === undefined ? null : { path, line: line ?? null };
143
+ if (location)
144
+ assertNodeLocation(analysis.files, location);
145
+ nodes.push({ id: nodeId, label, shape, status, layer, detail, location });
146
+ }
147
+ catch (err) {
148
+ errors.push(`- ${where}: ${err.message}`);
149
+ }
150
+ });
151
+ const links = [];
152
+ linkItems.forEach((raw, index) => {
153
+ const where = `links[${index}]`;
154
+ try {
155
+ const item = requireObject(raw, where);
156
+ rejectUnknownFields(item, LINK_INPUT_FIELDS, `${where}.`);
157
+ const from = requireString(item, "from", `${where}.`);
158
+ const to = requireString(item, "to", `${where}.`);
159
+ const label = nullableString(item, "label", `${where}.`) ?? null;
160
+ const unknown = [from, to].filter((end) => !nodeIds.has(end));
161
+ if (unknown.length > 0) {
162
+ throw new Error(`links ${from} -> ${to}, but node(s) ${[...new Set(unknown)].join(", ")} are not in "nodes".`);
163
+ }
164
+ links.push({ from, to, label });
165
+ }
166
+ catch (err) {
167
+ errors.push(`- ${where}: ${err.message}`);
168
+ }
169
+ });
170
+ const treeProblems = kind === "mindmap" ? mindmapProblems({ nodes, links }) : [];
171
+ for (const problem of treeProblems)
172
+ errors.push(`- mindmap: ${mindmapProblemText(problem)}`);
173
+ if (errors.length > 0) {
174
+ const tree = treeProblems.length > 0
175
+ ? "\nA mindmap is a tree: one root (the central subject), exactly one incoming link for every other node, no cycle. " +
176
+ "Use a flow or layers diagram for a graph."
177
+ : "";
178
+ throw new AnalysisError(`The diagram was not saved: ${errors.length} problem(s). Fix them and send the whole diagram again.\n${errors.join("\n")}${tree}`);
179
+ }
180
+ const draft = { id: requestedId ?? "draft", title, kind, layers, nodes, links, updatedAt: new Date().toISOString() };
181
+ let quality;
182
+ try {
183
+ quality = diagramQuality(draft, layoutDiagram(draft));
184
+ }
185
+ catch (err) {
186
+ throw new AnalysisError(`The diagram was not saved: it cannot be laid out (${err.message}).`);
187
+ }
188
+ let replaced = false;
189
+ let diagramId = "";
190
+ const updated = store.mutate(id, "agent", (draft) => {
191
+ diagramId = requestedId ?? itemId("diagram", new Set(draft.diagrams.map((diagram) => diagram.id)));
192
+ const diagram = { id: diagramId, title, kind, layers, nodes, links, updatedAt: new Date().toISOString() };
193
+ const index = draft.diagrams.findIndex((entry) => entry.id === diagramId);
194
+ replaced = index !== -1;
195
+ if (replaced)
196
+ draft.diagrams[index] = diagram;
197
+ else
198
+ draft.diagrams.push(diagram);
199
+ });
200
+ const lines = [
201
+ `${replaced ? "Replaced" : "Created"} diagram ${diagramId} "${title}" (${kind}, ${nodes.length} node(s), ${links.length} link(s)) ` +
202
+ `in "${updated.title}" (${updated.id}).`,
203
+ ...qualityLines(quality, kind, nodes.length),
204
+ `To change it, call set_diagram again with diagram_id "${diagramId}" and the complete diagram.`,
205
+ ];
206
+ return { result: textResult(lines.join("\n")), analysisId: updated.id };
207
+ }
@@ -0,0 +1,52 @@
1
+ /** Forme de réponse attendue par le SDK MCP pour un appel d'outil. */
2
+ export interface ToolResult {
3
+ [key: string]: unknown;
4
+ content: Array<{
5
+ type: "text";
6
+ text: string;
7
+ }>;
8
+ isError?: boolean;
9
+ }
10
+ /** Résultat d'un outil qui modifie une analyse : le texte et l'analyse concernée, à notifier. */
11
+ export interface MutationResult {
12
+ result: ToolResult;
13
+ analysisId: string;
14
+ }
15
+ /** Arguments d'un outil ou d'un élément de tableau. */
16
+ export type Args = Record<string, unknown> | undefined;
17
+ export declare function textResult(text: string): ToolResult;
18
+ export declare function errorResult(text: string): ToolResult;
19
+ /** Vrai si le champ est présent (ni absent ni `undefined`). */
20
+ export declare function has(args: Args, field: string): boolean;
21
+ /** Lit un argument texte obligatoire, rogné ; lève une erreur s'il est absent ou vide. */
22
+ export declare function requireString(args: Args, field: string, prefix?: string): string;
23
+ /** Lit un argument texte facultatif, rogné ; `undefined` s'il est absent, erreur s'il est vide ou d'un autre type. */
24
+ export declare function optionalString(args: Args, field: string, prefix?: string): string | undefined;
25
+ /**
26
+ * Lit un argument texte facultatif qui accepte `null` pour effacer la valeur :
27
+ * `undefined` s'il est absent, `null` s'il vaut null, le texte rogné sinon.
28
+ */
29
+ export declare function nullableString(args: Args, field: string, prefix?: string): string | null | undefined;
30
+ /** Lit un texte obligatoire non blanc, conservé tel quel (indentation et fins de ligne comprises). */
31
+ export declare function requireText(args: Args, field: string, prefix?: string): string;
32
+ /**
33
+ * Lit un texte facultatif conservé tel quel, `null` pour effacer : `undefined`
34
+ * s'il est absent. `allowEmpty` accepte la chaîne vide.
35
+ */
36
+ export declare function nullableText(args: Args, field: string, prefix?: string, allowEmpty?: boolean): string | null | undefined;
37
+ /** Lit une valeur parmi `values` ; erreur explicite listant les valeurs admises. */
38
+ export declare function requireEnum<T extends string>(args: Args, field: string, values: readonly T[], prefix?: string): T;
39
+ /** Lit une valeur facultative parmi `values` ; `undefined` si elle est absente. */
40
+ export declare function optionalEnum<T extends string>(args: Args, field: string, values: readonly T[], prefix?: string): T | undefined;
41
+ /** Lit un entier strictement positif obligatoire. */
42
+ export declare function requirePositiveInt(args: Args, field: string, prefix?: string): number;
43
+ /** Lit un entier strictement positif facultatif ; `undefined` s'il est absent. */
44
+ export declare function optionalPositiveInt(args: Args, field: string, prefix?: string): number | undefined;
45
+ /** Lit un tableau obligatoire, d'au moins `minItems` éléments. */
46
+ export declare function requireArray(args: Args, field: string, minItems?: number, prefix?: string): unknown[];
47
+ /** Vérifie qu'un élément de tableau est un objet et le renvoie. */
48
+ export declare function requireObject(value: unknown, where: string): Record<string, unknown>;
49
+ /** Lit un tableau de textes non vides, rognés. */
50
+ export declare function requireStringArray(args: Args, field: string, minItems?: number, prefix?: string): string[];
51
+ /** Lève une erreur si `args` contient des champs hors de `allowed` : une faute de frappe ne passe pas inaperçue. */
52
+ export declare function rejectUnknownFields(args: Args, allowed: readonly string[], prefix?: string): void;
@@ -0,0 +1,133 @@
1
+ export function textResult(text) {
2
+ return { content: [{ type: "text", text }] };
3
+ }
4
+ export function errorResult(text) {
5
+ return { content: [{ type: "text", text: `Error: ${text}` }], isError: true };
6
+ }
7
+ /** Nom d'un champ dans les messages d'erreur : `prefix` situe un élément de tableau, par ex. `findings[2].`. */
8
+ function label(field, prefix) {
9
+ return `"${prefix}${field}"`;
10
+ }
11
+ /** Vrai si le champ est présent (ni absent ni `undefined`). */
12
+ export function has(args, field) {
13
+ return args !== undefined && args[field] !== undefined;
14
+ }
15
+ /** Lit un argument texte obligatoire, rogné ; lève une erreur s'il est absent ou vide. */
16
+ export function requireString(args, field, prefix = "") {
17
+ const value = args?.[field];
18
+ if (typeof value !== "string" || value.trim() === "") {
19
+ throw new Error(`${label(field, prefix)} is required and must be a non-empty string.`);
20
+ }
21
+ return value.trim();
22
+ }
23
+ /** Lit un argument texte facultatif, rogné ; `undefined` s'il est absent, erreur s'il est vide ou d'un autre type. */
24
+ export function optionalString(args, field, prefix = "") {
25
+ if (!has(args, field))
26
+ return undefined;
27
+ return requireString(args, field, prefix);
28
+ }
29
+ /**
30
+ * Lit un argument texte facultatif qui accepte `null` pour effacer la valeur :
31
+ * `undefined` s'il est absent, `null` s'il vaut null, le texte rogné sinon.
32
+ */
33
+ export function nullableString(args, field, prefix = "") {
34
+ if (!has(args, field))
35
+ return undefined;
36
+ if (args[field] === null)
37
+ return null;
38
+ const value = args[field];
39
+ if (typeof value !== "string" || value.trim() === "") {
40
+ throw new Error(`${label(field, prefix)} must be a non-empty string, or null to clear it.`);
41
+ }
42
+ return value.trim();
43
+ }
44
+ /** Lit un texte obligatoire non blanc, conservé tel quel (indentation et fins de ligne comprises). */
45
+ export function requireText(args, field, prefix = "") {
46
+ const value = args?.[field];
47
+ if (typeof value !== "string" || value.trim() === "") {
48
+ throw new Error(`${label(field, prefix)} is required and must be a non-empty string.`);
49
+ }
50
+ return value;
51
+ }
52
+ /**
53
+ * Lit un texte facultatif conservé tel quel, `null` pour effacer : `undefined`
54
+ * s'il est absent. `allowEmpty` accepte la chaîne vide.
55
+ */
56
+ export function nullableText(args, field, prefix = "", allowEmpty = false) {
57
+ if (!has(args, field))
58
+ return undefined;
59
+ const value = args[field];
60
+ if (value === null)
61
+ return null;
62
+ if (typeof value !== "string" || (!allowEmpty && value.trim() === "")) {
63
+ const expected = allowEmpty ? "a string" : "a non-empty string";
64
+ throw new Error(`${label(field, prefix)} must be ${expected}, or null to clear it.`);
65
+ }
66
+ return value;
67
+ }
68
+ /** Lit une valeur parmi `values` ; erreur explicite listant les valeurs admises. */
69
+ export function requireEnum(args, field, values, prefix = "") {
70
+ const value = args?.[field];
71
+ if (typeof value !== "string" || !values.includes(value)) {
72
+ const got = value === undefined ? "nothing" : JSON.stringify(value);
73
+ throw new Error(`${label(field, prefix)} must be one of ${values.join(", ")} (got ${got}).`);
74
+ }
75
+ return value;
76
+ }
77
+ /** Lit une valeur facultative parmi `values` ; `undefined` si elle est absente. */
78
+ export function optionalEnum(args, field, values, prefix = "") {
79
+ if (!has(args, field))
80
+ return undefined;
81
+ return requireEnum(args, field, values, prefix);
82
+ }
83
+ /** Lit un entier strictement positif obligatoire. */
84
+ export function requirePositiveInt(args, field, prefix = "") {
85
+ const value = args?.[field];
86
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
87
+ throw new Error(`${label(field, prefix)} is required and must be an integer >= 1 (got ${JSON.stringify(value) ?? "nothing"}).`);
88
+ }
89
+ return value;
90
+ }
91
+ /** Lit un entier strictement positif facultatif ; `undefined` s'il est absent. */
92
+ export function optionalPositiveInt(args, field, prefix = "") {
93
+ if (!has(args, field))
94
+ return undefined;
95
+ return requirePositiveInt(args, field, prefix);
96
+ }
97
+ /** Lit un tableau obligatoire, d'au moins `minItems` éléments. */
98
+ export function requireArray(args, field, minItems = 0, prefix = "") {
99
+ const value = args?.[field];
100
+ if (!Array.isArray(value)) {
101
+ throw new Error(`${label(field, prefix)} is required and must be an array.`);
102
+ }
103
+ if (value.length < minItems) {
104
+ throw new Error(`${label(field, prefix)} must contain at least ${minItems} item(s).`);
105
+ }
106
+ return value;
107
+ }
108
+ /** Vérifie qu'un élément de tableau est un objet et le renvoie. */
109
+ export function requireObject(value, where) {
110
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
111
+ throw new Error(`"${where}" must be an object.`);
112
+ }
113
+ return value;
114
+ }
115
+ /** Lit un tableau de textes non vides, rognés. */
116
+ export function requireStringArray(args, field, minItems = 0, prefix = "") {
117
+ return requireArray(args, field, minItems, prefix).map((item, index) => {
118
+ if (typeof item !== "string" || item.trim() === "") {
119
+ throw new Error(`${label(`${field}[${index}]`, prefix)} must be a non-empty string.`);
120
+ }
121
+ return item.trim();
122
+ });
123
+ }
124
+ /** Lève une erreur si `args` contient des champs hors de `allowed` : une faute de frappe ne passe pas inaperçue. */
125
+ export function rejectUnknownFields(args, allowed, prefix = "") {
126
+ if (!args)
127
+ return;
128
+ const unknown = Object.keys(args).filter((key) => !allowed.includes(key));
129
+ if (unknown.length > 0) {
130
+ const where = prefix === "" ? "" : ` in ${prefix.replace(/\.$/, "")}`;
131
+ throw new Error(`Unknown field(s)${where}: ${unknown.join(", ")}. Accepted fields: ${allowed.join(", ")}.`);
132
+ }
133
+ }
@@ -0,0 +1,4 @@
1
+ import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type Args, type MutationResult } from "./types.js";
3
+ /** Met à jour le titre, le projet, la vue d'ensemble, le résumé et la demande initiale d'une analyse. */
4
+ export declare function updateAnalysis(store: AnalysisStore, args: Args): MutationResult;