@imenam/mcp-feature-analyzer 1.0.13 → 1.0.14

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 (98) hide show
  1. package/README.md +351 -350
  2. package/dist/shared/context-expansion.d.ts +20 -10
  3. package/dist/shared/context-expansion.js +69 -29
  4. package/dist/shared/inline-diff.d.ts +26 -0
  5. package/dist/shared/inline-diff.js +116 -0
  6. package/dist/shared/schemas/analysis.schema.d.ts +2 -0
  7. package/dist/shared/schemas/analysis.schema.js +2 -0
  8. package/dist/shared/schemas/api.schema.d.ts +6 -1
  9. package/dist/shared/schemas/api.schema.js +5 -1
  10. package/dist/src/core/invariants.d.ts +1 -0
  11. package/dist/src/core/invariants.js +6 -0
  12. package/dist/src/core/migrate.d.ts +2 -0
  13. package/dist/src/core/migrate.js +11 -1
  14. package/dist/src/core/review-actions.d.ts +4 -1
  15. package/dist/src/core/review-actions.js +9 -1
  16. package/dist/src/index.js +2 -1
  17. package/dist/src/tools/delete-findings.d.ts +3 -2
  18. package/dist/src/tools/delete-findings.js +10 -3
  19. package/gui/dist/assets/{angular-html-C8yUP2eH.js → angular-html-B6_NRnCN.js} +1 -1
  20. package/gui/dist/assets/{angular-ts-DzcU1T2K.js → angular-ts-B9erDs6c.js} +1 -1
  21. package/gui/dist/assets/{apl-Is94CrNC.js → apl-DjvcB9sT.js} +1 -1
  22. package/gui/dist/assets/{astro-CZVpOEMl.js → astro-CZhgIppg.js} +1 -1
  23. package/gui/dist/assets/{blade-B8DVzBXI.js → blade-e8oMNvMd.js} +1 -1
  24. package/gui/dist/assets/{c-DwvJqxYQ.js → c-BrK5UjTM.js} +1 -1
  25. package/gui/dist/assets/{chapel-B0A7h2gJ.js → chapel-BYqvw2uH.js} +1 -1
  26. package/gui/dist/assets/{cobol-ixuUpb8j.js → cobol-CwH5fqfp.js} +1 -1
  27. package/gui/dist/assets/{coffee-B6Gt7KW6.js → coffee-TLGXwX2-.js} +1 -1
  28. package/gui/dist/assets/{cpp-DdTMcCH3.js → cpp-BvSDADNj.js} +1 -1
  29. package/gui/dist/assets/{crystal-DtwB5KRO.js → crystal-iHjso4ux.js} +1 -1
  30. package/gui/dist/assets/{css-r05hkkmY.js → css-CBe1Qi0-.js} +1 -1
  31. package/gui/dist/assets/{edge-BUCnVtWw.js → edge-CyZLnGaJ.js} +1 -1
  32. package/gui/dist/assets/{elixir-Dj0uUBSU.js → elixir-D2tsTgpS.js} +1 -1
  33. package/gui/dist/assets/{elm-DX0-ItU4.js → elm-M0avAcen.js} +1 -1
  34. package/gui/dist/assets/{erb-BNe-5sal.js → erb-BLSscFWl.js} +1 -1
  35. package/gui/dist/assets/{git-rebase-xOi6nkoo.js → git-rebase-CqSWXxzq.js} +1 -1
  36. package/gui/dist/assets/{glimmer-js-DsDY8Yny.js → glimmer-js-SVN1ZH84.js} +1 -1
  37. package/gui/dist/assets/{glimmer-ts-Dy87T0gH.js → glimmer-ts-Cwgq9uxW.js} +1 -1
  38. package/gui/dist/assets/{glsl-C9ZlHNoz.js → glsl-BrioHYM5.js} +1 -1
  39. package/gui/dist/assets/{graphql-B4nECVov.js → graphql-DDdKMtYR.js} +1 -1
  40. package/gui/dist/assets/{hack-BSLHBRVi.js → hack-BHZ0geAY.js} +1 -1
  41. package/gui/dist/assets/{haml-D2SEwaEa.js → haml-WIiyynmN.js} +1 -1
  42. package/gui/dist/assets/{handlebars-Dd8lUU5h.js → handlebars-BQroB-k3.js} +1 -1
  43. package/gui/dist/assets/{html-vn9Wh61S.js → html-B79Efp59.js} +1 -1
  44. package/gui/dist/assets/{html-derivative-BeVUcZEC.js → html-derivative-QmHc8kDj.js} +1 -1
  45. package/gui/dist/assets/{http-CeXJu1sw.js → http-yRG3c5rf.js} +1 -1
  46. package/gui/dist/assets/{hurl-B1QPHVeP.js → hurl-v973ESCN.js} +1 -1
  47. package/gui/dist/assets/index-B6olMl4d.css +1 -0
  48. package/gui/dist/assets/{index-BTbIxBJv.js → index-O0__xdj-.js} +12 -12
  49. package/gui/dist/assets/{java-DCIVTTpQ.js → java-BVWDaPHa.js} +1 -1
  50. package/gui/dist/assets/{javascript-P08pLFac.js → javascript-F6vvVyv2.js} +1 -1
  51. package/gui/dist/assets/{jinja-BdCJo5BJ.js → jinja-DWb3onNx.js} +1 -1
  52. package/gui/dist/assets/{jison-Bz2hFddv.js → jison-BwYJk9jQ.js} +1 -1
  53. package/gui/dist/assets/{json-C8EM4NNA.js → json-D4GGWn-t.js} +1 -1
  54. package/gui/dist/assets/{jsx-CkeBmlYM.js → jsx-DRy2cnLL.js} +1 -1
  55. package/gui/dist/assets/{julia-DdRWaHNt.js → julia-DZbd-p26.js} +1 -1
  56. package/gui/dist/assets/{just-Bw9kWd7J.js → just-f5z-QSLh.js} +1 -1
  57. package/gui/dist/assets/{latex-8jtj5txb.js → latex-x5U08LY6.js} +1 -1
  58. package/gui/dist/assets/{liquid-1NrB1sFV.js → liquid-YlLxXWQU.js} +1 -1
  59. package/gui/dist/assets/{lua-zyROpOUJ.js → lua-BETZr4ZG.js} +1 -1
  60. package/gui/dist/assets/{marko-B4Gx1REj.js → marko-Ci2LOIA3.js} +1 -1
  61. package/gui/dist/assets/{mdc-hlQzSEED.js → mdc-CNgtEUaF.js} +1 -1
  62. package/gui/dist/assets/{nginx-2oeh5xP6.js → nginx-CjN1FzJ1.js} +1 -1
  63. package/gui/dist/assets/{nim-5hoXcnXR.js → nim--Tw7Q7l1.js} +1 -1
  64. package/gui/dist/assets/{org-0LYtlaYk.js → org-D-0ign4q.js} +1 -1
  65. package/gui/dist/assets/{perl-DqUdWKZG.js → perl-CpGWpAqK.js} +1 -1
  66. package/gui/dist/assets/{php-BC56Y6Zn.js → php-DZMELnJP.js} +1 -1
  67. package/gui/dist/assets/{pug-Do_wQNPI.js → pug-DnnE3CD9.js} +1 -1
  68. package/gui/dist/assets/{qml-BwknWxId.js → qml-1YULs32S.js} +1 -1
  69. package/gui/dist/assets/{r-C6bHxkgQ.js → r-BKebVmGE.js} +1 -1
  70. package/gui/dist/assets/{razor-BEyuM7MM.js → razor-B6Rp2GI9.js} +1 -1
  71. package/gui/dist/assets/{regexp-CxH6WQ4V.js → regexp-BSdZUJch.js} +1 -1
  72. package/gui/dist/assets/{rst-DVGF9bH2.js → rst-CDfh7Yx8.js} +1 -1
  73. package/gui/dist/assets/{ruby-DHmaoIVS.js → ruby-DH2yECCv.js} +1 -1
  74. package/gui/dist/assets/{sas-h0gvSTcH.js → sas-D4xSfkgc.js} +1 -1
  75. package/gui/dist/assets/{scss-BnHxKO0L.js → scss-DKXhKwKk.js} +1 -1
  76. package/gui/dist/assets/{shellscript-C8GolBeA.js → shellscript-Cdvelqxj.js} +1 -1
  77. package/gui/dist/assets/{shellsession-BRGv8dCp.js → shellsession-BIsjLWb8.js} +1 -1
  78. package/gui/dist/assets/{soy-kgRIk1Av.js → soy-DAHoyBkO.js} +1 -1
  79. package/gui/dist/assets/{sql-D2zf6QGW.js → sql-Cw_MygQA.js} +1 -1
  80. package/gui/dist/assets/{stata-Cv5nQVjB.js → stata-XN5LKohx.js} +1 -1
  81. package/gui/dist/assets/{surrealql-Dyy-Mx5h.js → surrealql-BFZZr6ln.js} +1 -1
  82. package/gui/dist/assets/{svelte-Qv7HyAj6.js → svelte-PSD0_NIJ.js} +1 -1
  83. package/gui/dist/assets/{templ-GwFzX2N-.js → templ-DR1BQIMZ.js} +1 -1
  84. package/gui/dist/assets/{tex-BwmU6aP3.js → tex-FZ5ZE2Wi.js} +1 -1
  85. package/gui/dist/assets/{ts-tags-BdoQY3Va.js → ts-tags-J0uFOrqJ.js} +1 -1
  86. package/gui/dist/assets/{tsx-DQhkUiIl.js → tsx-CKQtNFtf.js} +1 -1
  87. package/gui/dist/assets/{twig-DypZ0Z28.js → twig-BaV21FxL.js} +1 -1
  88. package/gui/dist/assets/{typescript-BwW-Y5sm.js → typescript-DrRvnRdv.js} +1 -1
  89. package/gui/dist/assets/{typst-YWpvbo8x.js → typst-CyyoMVtl.js} +1 -1
  90. package/gui/dist/assets/{vue-C6O-uzDz.js → vue-C6CzC5nh.js} +1 -1
  91. package/gui/dist/assets/{vue-html-C3DSxeyo.js → vue-html-BhqRmn6o.js} +1 -1
  92. package/gui/dist/assets/{vue-vine-TAhDNl0X.js → vue-vine-D-akwyey.js} +1 -1
  93. package/gui/dist/assets/{xml-B2vOHzhB.js → xml-BSC8IfpC.js} +1 -1
  94. package/gui/dist/assets/{xsl-DlDYp1Ks.js → xsl-hlSzALu7.js} +1 -1
  95. package/gui/dist/assets/{yaml-BegRJSBb.js → yaml-vbTiXN5G.js} +1 -1
  96. package/gui/dist/index.html +2 -2
  97. package/package.json +1 -1
  98. package/gui/dist/assets/index-oME7W1eA.css +0 -1
@@ -1,29 +1,39 @@
1
1
  import type { FileDiff } from "./schemas/diff.schema.js";
2
2
  /** Nombre de lignes de contexte ajoutées, ou retirées, au-dessus et en dessous d'un bloc par clic. */
3
3
  export declare const CONTEXT_STEP = 5;
4
- /** État du contexte d'un bloc élargi. */
4
+ /** État du contexte d'un bloc affiché. */
5
5
  export interface HunkContext {
6
6
  /** Lignes de contexte affichées au-dessus de la première ligne modifiée du bloc. */
7
7
  context: number;
8
- /** Lignes de contexte ajoutées au-dessus et en dessous du bloc. */
9
- above: number;
10
- below: number;
8
+ /** Indices des blocs du diff d'origine réunis dans ce bloc. */
9
+ members: number[];
10
+ /** Le bloc porte des lignes de contexte ajoutées. */
11
+ canShrink: boolean;
11
12
  /** Le fichier a encore des lignes à ajouter au-dessus ou en dessous du bloc. */
12
13
  canGrow: boolean;
13
14
  }
14
15
  export interface ExpandedDiff {
15
16
  fileDiff: FileDiff;
16
- /** Contexte de chaque bloc, dans l'ordre des blocs. */
17
+ /** Contexte de chaque bloc affiché, dans l'ordre des blocs. */
17
18
  hunks: HunkContext[];
19
+ /** Lignes ajoutées autour de chaque bloc du diff d'origine, du côté qui en compte le plus. */
20
+ extras: number[];
18
21
  /** Numéros, côté « nouveau », des lignes de contexte ajoutées. */
19
22
  added: Set<number>;
20
23
  }
21
24
  /**
22
25
  * Diff de `fileDiff` dont chaque bloc `i` gagne jusqu'à `extras[i]` lignes de
23
- * contexte au-dessus et autant en dessous, tirées de `newContent`. Les blocs
24
- * ne fusionnent pas : les lignes entre deux blocs vont d'abord au bloc du
25
- * dessus, puis au bloc du dessous, sans jamais se recouvrir ; le début et la
26
- * fin du fichier bornent aussi l'élargissement. Un bloc sans ligne ajoutée
27
- * reste inchangé. Null quand le snapshot ne conserve pas le contenu du fichier.
26
+ * contexte au-dessus et autant en dessous, tirées de `newContent`. Les lignes
27
+ * entre deux blocs vont d'abord au bloc du dessus, puis au bloc du dessous,
28
+ * sans jamais se recouvrir ; le début et la fin du fichier bornent aussi
29
+ * l'élargissement. Deux blocs que plus aucune ligne masquée ne sépare n'en
30
+ * forment plus qu'un. Un bloc sans ligne ajoutée ni voisin réuni reste
31
+ * inchangé. Null quand le snapshot ne conserve pas le contenu du fichier.
28
32
  */
29
33
  export declare function expandContext(fileDiff: FileDiff, extras: readonly number[]): ExpandedDiff | null;
34
+ /**
35
+ * Élargissement de chaque bloc d'origine après un clic sur « +5 » (`delta`
36
+ * `1`) ou « −5 » (`-1`) dans l'en-tête du bloc affiché `hunkIndex` : chacun
37
+ * des blocs qu'il réunit gagne ou perd une tranche de lignes.
38
+ */
39
+ export declare function stepContext(expanded: ExpandedDiff, hunkIndex: number, delta: 1 | -1): number[];
@@ -5,6 +5,20 @@ export const CONTEXT_STEP = 5;
5
5
  function headerRange(start, count) {
6
6
  return count === 1 ? `${start}` : `${start},${count}`;
7
7
  }
8
+ /** Bloc couvrant les plages données, en-tête recalculé à partir de celui de `source`. */
9
+ function rangedHunk(source, oldStart, newStart, lines) {
10
+ const oldLines = lines.filter((line) => line.type !== "add").length;
11
+ const newLines = lines.filter((line) => line.type !== "del").length;
12
+ const rest = source.header.replace(/^@@[^@]*@@/, "");
13
+ return {
14
+ header: `@@ -${headerRange(oldStart, oldLines)} +${headerRange(newStart, newLines)} @@${rest}`,
15
+ oldStart,
16
+ oldLines,
17
+ newStart,
18
+ newLines,
19
+ lines,
20
+ };
21
+ }
8
22
  /** Nombre de lignes de contexte en tête du bloc, avant sa première ligne modifiée. */
9
23
  function leadingContext(hunk) {
10
24
  const index = hunk.lines.findIndex((line) => line.type !== "context");
@@ -12,19 +26,19 @@ function leadingContext(hunk) {
12
26
  }
13
27
  /**
14
28
  * Diff de `fileDiff` dont chaque bloc `i` gagne jusqu'à `extras[i]` lignes de
15
- * contexte au-dessus et autant en dessous, tirées de `newContent`. Les blocs
16
- * ne fusionnent pas : les lignes entre deux blocs vont d'abord au bloc du
17
- * dessus, puis au bloc du dessous, sans jamais se recouvrir ; le début et la
18
- * fin du fichier bornent aussi l'élargissement. Un bloc sans ligne ajoutée
19
- * reste inchangé. Null quand le snapshot ne conserve pas le contenu du fichier.
29
+ * contexte au-dessus et autant en dessous, tirées de `newContent`. Les lignes
30
+ * entre deux blocs vont d'abord au bloc du dessus, puis au bloc du dessous,
31
+ * sans jamais se recouvrir ; le début et la fin du fichier bornent aussi
32
+ * l'élargissement. Deux blocs que plus aucune ligne masquée ne sépare n'en
33
+ * forment plus qu'un. Un bloc sans ligne ajoutée ni voisin réuni reste
34
+ * inchangé. Null quand le snapshot ne conserve pas le contenu du fichier.
20
35
  */
21
36
  export function expandContext(fileDiff, extras) {
22
37
  if (fileDiff.newContent === null)
23
38
  return null;
24
39
  const content = splitLines(fileDiff.newContent);
25
40
  const added = new Set();
26
- const contexts = [];
27
- const hunks = [];
41
+ const parts = [];
28
42
  // Écart entre les numéros des deux côtés avant le bloc courant, et dernière ligne déjà affichée.
29
43
  let offset = 0;
30
44
  let shownUntil = 0;
@@ -44,32 +58,58 @@ export function expandContext(fileDiff, extras) {
44
58
  added.add(newNo);
45
59
  return { type: "context", oldNo: newNo + shift, newNo, text: content[newNo - 1] };
46
60
  };
47
- if (above === 0 && below === 0) {
48
- hunks.push(hunk);
49
- }
50
- else {
51
- const lines = [
61
+ const expanded = above === 0 && below === 0
62
+ ? hunk
63
+ : rangedHunk(hunk, (hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart) - above, first - above, [
52
64
  ...Array.from({ length: above }, (_, k) => contextLine(first - above + k, offset)),
53
65
  ...hunk.lines,
54
66
  ...Array.from({ length: below }, (_, k) => contextLine(last + 1 + k, after)),
55
- ];
56
- const oldStart = (hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart) - above;
57
- const newStart = first - above;
58
- const oldLines = hunk.oldLines + above + below;
59
- const newLines = hunk.newLines + above + below;
60
- const rest = hunk.header.replace(/^@@[^@]*@@/, "");
61
- hunks.push({
62
- header: `@@ -${headerRange(oldStart, oldLines)} +${headerRange(newStart, newLines)} @@${rest}`,
63
- oldStart,
64
- oldLines,
65
- newStart,
66
- newLines,
67
- lines,
68
- });
69
- }
70
- contexts.push({ context: leadingContext(hunk) + above, above, below, canGrow: above < aboveRoom || below < belowRoom });
67
+ ]);
68
+ parts.push({
69
+ hunk: expanded,
70
+ above,
71
+ below,
72
+ canGrow: above < aboveRoom || below < belowRoom,
73
+ joinsPrevious: index > 0 && above === aboveRoom,
74
+ });
71
75
  offset = after;
72
76
  shownUntil = last + below;
73
77
  });
74
- return { fileDiff: { ...fileDiff, hunks }, hunks: contexts, added };
78
+ const groups = [];
79
+ parts.forEach((part, index) => {
80
+ if (part.joinsPrevious)
81
+ groups[groups.length - 1].push(index);
82
+ else
83
+ groups.push([index]);
84
+ });
85
+ const hunks = groups.map((members) => {
86
+ if (members.length === 1)
87
+ return parts[members[0]].hunk;
88
+ const head = parts[members[0]].hunk;
89
+ return rangedHunk(head, head.oldLines === 0 ? head.oldStart + 1 : head.oldStart, head.newLines === 0 ? head.newStart + 1 : head.newStart, members.flatMap((member) => parts[member].hunk.lines));
90
+ });
91
+ const contexts = groups.map((members) => ({
92
+ context: leadingContext(fileDiff.hunks[members[0]]) + parts[members[0]].above,
93
+ members,
94
+ canShrink: members.some((member) => parts[member].above > 0 || parts[member].below > 0),
95
+ canGrow: members.some((member) => parts[member].canGrow),
96
+ }));
97
+ return {
98
+ fileDiff: { ...fileDiff, hunks },
99
+ hunks: contexts,
100
+ extras: parts.map((part) => Math.max(part.above, part.below)),
101
+ added,
102
+ };
103
+ }
104
+ /**
105
+ * Élargissement de chaque bloc d'origine après un clic sur « +5 » (`delta`
106
+ * `1`) ou « −5 » (`-1`) dans l'en-tête du bloc affiché `hunkIndex` : chacun
107
+ * des blocs qu'il réunit gagne ou perd une tranche de lignes.
108
+ */
109
+ export function stepContext(expanded, hunkIndex, delta) {
110
+ const next = [...expanded.extras];
111
+ for (const member of expanded.hunks[hunkIndex].members) {
112
+ next[member] = Math.max(0, next[member] + delta * CONTEXT_STEP);
113
+ }
114
+ return next;
75
115
  }
@@ -0,0 +1,26 @@
1
+ import type { DiffLine } from "./schemas/diff.schema.js";
2
+ import type { MergedLine } from "./reindent.js";
3
+ /** Plage de caractères d'une ligne, `start` inclus, `end` exclu. */
4
+ export interface CharRange {
5
+ start: number;
6
+ end: number;
7
+ }
8
+ /** Parties d'une ligne supprimée et de la ligne ajoutée qui la remplace qui diffèrent de l'autre. */
9
+ export interface InlineChanges {
10
+ removed: CharRange[];
11
+ added: CharRange[];
12
+ }
13
+ /**
14
+ * Parties qui diffèrent entre une ligne supprimée et la ligne ajoutée qui la
15
+ * remplace, comparées mot à mot. Null quand les lignes sont identiques, trop
16
+ * longues, ou trop différentes pour qu'un surlignage partiel ait un sens :
17
+ * moins de la moitié de leurs mots, hors espaces, en commun.
18
+ */
19
+ export declare function inlineChanges(oldText: string, newText: string): InlineChanges | null;
20
+ /**
21
+ * Parties modifiées des lignes d'un bloc. Dans chaque suite de lignes supprimées
22
+ * suivie de lignes ajoutées, hors lignes dont seule l'indentation change, la
23
+ * i-ème ligne supprimée est comparée à la i-ème ligne ajoutée. Seules les lignes
24
+ * dont la comparaison aboutit figurent dans le résultat.
25
+ */
26
+ export declare function inlineChangesOfLines(lines: MergedLine[]): Map<DiffLine, CharRange[]>;
@@ -0,0 +1,116 @@
1
+ /** Nombre maximal de mots d'une ligne au-delà duquel la comparaison n'est pas faite. */
2
+ const MAX_TOKENS = 400;
3
+ /** Part minimale des mots, hors espaces, communs aux deux lignes pour les comparer mot à mot. */
4
+ const MIN_SIMILARITY = 0.5;
5
+ /** Mots d'une ligne : identifiants et nombres, suites d'espaces, et chaque autre caractère seul. */
6
+ function tokenize(text) {
7
+ const tokens = [];
8
+ for (const match of text.matchAll(/[\p{L}\p{N}_$]+|\s+|[^\p{L}\p{N}_$\s]/gu)) {
9
+ tokens.push({ text: match[0], start: match.index });
10
+ }
11
+ return tokens;
12
+ }
13
+ function isBlank(token) {
14
+ return token.text.trim() === "";
15
+ }
16
+ /** Indices des mots de `a` et de `b` qui appartiennent à leur plus longue suite commune. */
17
+ function commonTokens(a, b) {
18
+ const width = b.length + 1;
19
+ // lengths[i * width + j] : longueur de la plus longue suite commune de a[i..] et b[j..].
20
+ const lengths = new Uint32Array((a.length + 1) * width);
21
+ for (let i = a.length - 1; i >= 0; i--) {
22
+ for (let j = b.length - 1; j >= 0; j--) {
23
+ lengths[i * width + j] =
24
+ a[i].text === b[j].text ? lengths[(i + 1) * width + j + 1] + 1 : Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]);
25
+ }
26
+ }
27
+ const inA = new Set();
28
+ const inB = new Set();
29
+ let i = 0;
30
+ let j = 0;
31
+ while (i < a.length && j < b.length) {
32
+ if (a[i].text === b[j].text) {
33
+ inA.add(i++);
34
+ inB.add(j++);
35
+ }
36
+ else if (lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])
37
+ i++;
38
+ else
39
+ j++;
40
+ }
41
+ return { inA, inB };
42
+ }
43
+ /**
44
+ * Plages couvertes par les mots absents de `common`, les mots voisins réunis en
45
+ * une plage ; les espaces en bord de plage n'en font pas partie.
46
+ */
47
+ function changedRanges(tokens, common) {
48
+ const runs = [];
49
+ tokens.forEach((token, index) => {
50
+ if (common.has(index))
51
+ return;
52
+ const last = runs[runs.length - 1];
53
+ if (last && last[last.length - 1] === tokens[index - 1])
54
+ last.push(token);
55
+ else
56
+ runs.push([token]);
57
+ });
58
+ return runs.flatMap((run) => {
59
+ const first = run.findIndex((token) => !isBlank(token));
60
+ if (first === -1)
61
+ return [];
62
+ const last = run.length - 1 - [...run].reverse().findIndex((token) => !isBlank(token));
63
+ return [{ start: run[first].start, end: run[last].start + run[last].text.length }];
64
+ });
65
+ }
66
+ /**
67
+ * Parties qui diffèrent entre une ligne supprimée et la ligne ajoutée qui la
68
+ * remplace, comparées mot à mot. Null quand les lignes sont identiques, trop
69
+ * longues, ou trop différentes pour qu'un surlignage partiel ait un sens :
70
+ * moins de la moitié de leurs mots, hors espaces, en commun.
71
+ */
72
+ export function inlineChanges(oldText, newText) {
73
+ if (oldText === newText)
74
+ return null;
75
+ const oldTokens = tokenize(oldText);
76
+ const newTokens = tokenize(newText);
77
+ if (oldTokens.length > MAX_TOKENS || newTokens.length > MAX_TOKENS)
78
+ return null;
79
+ const { inA, inB } = commonTokens(oldTokens, newTokens);
80
+ const shared = [...inA].filter((index) => !isBlank(oldTokens[index])).length;
81
+ const longest = Math.max(oldTokens.filter((token) => !isBlank(token)).length, newTokens.filter((token) => !isBlank(token)).length);
82
+ if (longest === 0 || shared / longest < MIN_SIMILARITY)
83
+ return null;
84
+ return { removed: changedRanges(oldTokens, inA), added: changedRanges(newTokens, inB) };
85
+ }
86
+ /**
87
+ * Parties modifiées des lignes d'un bloc. Dans chaque suite de lignes supprimées
88
+ * suivie de lignes ajoutées, hors lignes dont seule l'indentation change, la
89
+ * i-ème ligne supprimée est comparée à la i-ème ligne ajoutée. Seules les lignes
90
+ * dont la comparaison aboutit figurent dans le résultat.
91
+ */
92
+ export function inlineChangesOfLines(lines) {
93
+ const result = new Map();
94
+ const single = (index, type) => lines[index]?.oldLine === null && lines[index].line.type === type;
95
+ let index = 0;
96
+ while (index < lines.length) {
97
+ if (!single(index, "del")) {
98
+ index++;
99
+ continue;
100
+ }
101
+ const removed = [];
102
+ const added = [];
103
+ while (single(index, "del"))
104
+ removed.push(lines[index++].line);
105
+ while (single(index, "add"))
106
+ added.push(lines[index++].line);
107
+ for (let pair = 0; pair < Math.min(removed.length, added.length); pair++) {
108
+ const changes = inlineChanges(removed[pair].text, added[pair].text);
109
+ if (!changes)
110
+ continue;
111
+ result.set(removed[pair], changes.removed);
112
+ result.set(added[pair], changes.added);
113
+ }
114
+ }
115
+ return result;
116
+ }
@@ -114,6 +114,7 @@ export declare const NoteSchema: z.ZodObject<{
114
114
  path: z.ZodString;
115
115
  line: z.ZodNullable<z.ZodNumber>;
116
116
  }, z.core.$strip>>;
117
+ findingId: z.ZodNullable<z.ZodString>;
117
118
  text: z.ZodString;
118
119
  createdAt: z.ZodString;
119
120
  }, z.core.$strip>;
@@ -310,6 +311,7 @@ export declare const AnalysisSchema: z.ZodObject<{
310
311
  path: z.ZodString;
311
312
  line: z.ZodNullable<z.ZodNumber>;
312
313
  }, z.core.$strip>>;
314
+ findingId: z.ZodNullable<z.ZodString>;
313
315
  text: z.ZodString;
314
316
  createdAt: z.ZodString;
315
317
  }, z.core.$strip>>;
@@ -88,6 +88,8 @@ export const NoteLocationSchema = z.object({
88
88
  export const NoteSchema = z.object({
89
89
  id: ItemIdSchema,
90
90
  location: NoteLocationSchema.nullable(),
91
+ /** Constat auquel la remarque répond ; null pour une remarque libre. Supprimer le constat supprime la remarque. */
92
+ findingId: ItemIdSchema.nullable(),
91
93
  text: z.string().min(1),
92
94
  createdAt: IsoDateSchema,
93
95
  });
@@ -12,10 +12,14 @@ export declare const SetFindingStatusBodySchema: z.ZodObject<{
12
12
  }>;
13
13
  baseRevision: z.ZodNumber;
14
14
  }, z.core.$strict>;
15
- /** Remarque sur une ligne du côté « nouveau » (`line`), ou sur le fichier entier (`line` absent ou null). */
15
+ /**
16
+ * Remarque sur une ligne du côté « nouveau » (`line`), ou sur le fichier entier (`line` absent ou null).
17
+ * `findingId` lie la remarque au constat auquel elle répond.
18
+ */
16
19
  export declare const AddNoteBodySchema: z.ZodObject<{
17
20
  path: z.ZodString;
18
21
  line: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
22
+ findingId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
19
23
  text: z.ZodString;
20
24
  baseRevision: z.ZodNumber;
21
25
  }, z.core.$strict>;
@@ -71,6 +75,7 @@ export declare const AddNoteRequestSchema: z.ZodObject<{
71
75
  body: z.ZodObject<{
72
76
  path: z.ZodString;
73
77
  line: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
78
+ findingId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
74
79
  text: z.ZodString;
75
80
  baseRevision: z.ZodNumber;
76
81
  }, z.core.$strict>;
@@ -15,10 +15,14 @@ export const SetFindingStatusBodySchema = z.strictObject({
15
15
  status: z.enum(FINDING_STATUSES),
16
16
  baseRevision: BaseRevisionSchema,
17
17
  });
18
- /** Remarque sur une ligne du côté « nouveau » (`line`), ou sur le fichier entier (`line` absent ou null). */
18
+ /**
19
+ * Remarque sur une ligne du côté « nouveau » (`line`), ou sur le fichier entier (`line` absent ou null).
20
+ * `findingId` lie la remarque au constat auquel elle répond.
21
+ */
19
22
  export const AddNoteBodySchema = z.strictObject({
20
23
  path: z.string().min(1),
21
24
  line: z.number().int().nullable().optional(),
25
+ findingId: z.string().min(1).nullable().optional(),
22
26
  text: z.string().trim().min(1, "the note text is empty"),
23
27
  baseRevision: BaseRevisionSchema,
24
28
  });
@@ -13,6 +13,7 @@ import type { Analysis } from "../../shared/schemas/analysis.schema.js";
13
13
  * disponible et endLine ≤ nombre de lignes ;
14
14
  * - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
15
15
  * déclarée dans `layers`, sans doublon ;
16
+ * - une remarque liée à un constat vise un constat existant ;
16
17
  * - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
17
18
  */
18
19
  export declare function findInvariantViolations(analysis: Analysis): string[];
@@ -24,6 +24,7 @@ function duplicates(ids) {
24
24
  * disponible et endLine ≤ nombre de lignes ;
25
25
  * - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
26
26
  * déclarée dans `layers`, sans doublon ;
27
+ * - une remarque liée à un constat vise un constat existant ;
27
28
  * - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
28
29
  */
29
30
  export function findInvariantViolations(analysis) {
@@ -136,6 +137,11 @@ export function findInvariantViolations(analysis) {
136
137
  if (!noteIds.has(id))
137
138
  problems.push(`review selects unknown note "${id}"`);
138
139
  }
140
+ for (const note of analysis.notes) {
141
+ if (note.findingId !== null && !findingIds.has(note.findingId)) {
142
+ problems.push(`note "${note.id}" answers unknown finding "${note.findingId}"`);
143
+ }
144
+ }
139
145
  if (review.state === "submitted" && (review.decision === null || review.submittedAt === null)) {
140
146
  problems.push("a submitted review needs a decision and a submittedAt date");
141
147
  }
@@ -14,6 +14,8 @@ export declare const addProject: MigrationStep;
14
14
  export declare const addOverview: MigrationStep;
15
15
  /** Ajoute `explanations` vide : l'analyse n'a pas encore d'explication. */
16
16
  export declare const addExplanations: MigrationStep;
17
+ /** Ajoute `findingId` à null sur les remarques qui n'en ont pas : elles restent des remarques libres. */
18
+ export declare const addNoteFindingIds: MigrationStep;
17
19
  /** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
18
20
  export declare const ANALYSIS_MIGRATIONS: readonly MigrationStep[];
19
21
  /** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
@@ -26,8 +26,18 @@ export const addProject = (doc) => {
26
26
  export const addOverview = (doc) => (doc.overview !== undefined ? null : { ...doc, overview: null });
27
27
  /** Ajoute `explanations` vide : l'analyse n'a pas encore d'explication. */
28
28
  export const addExplanations = (doc) => (doc.explanations !== undefined ? null : { ...doc, explanations: [] });
29
+ /** Ajoute `findingId` à null sur les remarques qui n'en ont pas : elles restent des remarques libres. */
30
+ export const addNoteFindingIds = (doc) => {
31
+ if (!Array.isArray(doc.notes))
32
+ return null;
33
+ const notes = doc.notes;
34
+ const missing = (note) => !!note && typeof note === "object" && note.findingId === undefined;
35
+ if (!notes.some(missing))
36
+ return null;
37
+ return { ...doc, notes: notes.map((note) => (missing(note) ? { ...note, findingId: null } : note)) };
38
+ };
29
39
  /** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
30
- export const ANALYSIS_MIGRATIONS = [addProject, addOverview, addExplanations];
40
+ export const ANALYSIS_MIGRATIONS = [addProject, addOverview, addExplanations, addNoteFindingIds];
31
41
  /** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
32
42
  export const SNAPSHOT_MIGRATIONS = [];
33
43
  /** Applique `steps` à la suite. Renvoie null si aucune étape n'a modifié le document. */
@@ -13,7 +13,10 @@ export declare function setFileReviewed(store: AnalysisStore, analysisId: string
13
13
  * le réancre.
14
14
  */
15
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. */
16
+ /**
17
+ * Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier,
18
+ * liée au constat `findingId` quand elle y répond.
19
+ */
17
20
  export declare function addNote(store: AnalysisStore, analysisId: string, body: AddNoteBody): Analysis;
18
21
  /**
19
22
  * Remplace le texte d'une remarque. Quand elle fait partie d'une revue soumise,
@@ -34,14 +34,22 @@ export function setFindingStatus(store, analysisId, findingId, body) {
34
34
  finding.status = body.status;
35
35
  }, { baseRevision: body.baseRevision });
36
36
  }
37
- /** Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier. */
37
+ /**
38
+ * Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier,
39
+ * liée au constat `findingId` quand elle y répond.
40
+ */
38
41
  export function addNote(store, analysisId, body) {
39
42
  return store.mutate(analysisId, "user", (draft) => {
40
43
  const location = { path: body.path, line: body.line ?? null };
41
44
  assertNoteLocation(draft.files, location);
45
+ const findingId = body.findingId ?? null;
46
+ if (findingId !== null && !draft.findings.some((finding) => finding.id === findingId)) {
47
+ throw new NotFoundError(`Finding "${findingId}" not found in analysis "${analysisId}".`);
48
+ }
42
49
  draft.notes.push({
43
50
  id: itemId("note", new Set(draft.notes.map((note) => note.id))),
44
51
  location,
52
+ findingId,
45
53
  text: body.text,
46
54
  createdAt: new Date().toISOString(),
47
55
  });
package/dist/src/index.js CHANGED
@@ -523,7 +523,8 @@ const TOOLS = [
523
523
  },
524
524
  {
525
525
  name: "delete_findings",
526
- description: "Delete findings by id, typically once refresh_analysis shows them solved. If any id is unknown, nothing is deleted.",
526
+ description: "Delete findings by id, typically once refresh_analysis shows them solved. The reviewer notes written on these findings are deleted with them. " +
527
+ "If any id is unknown, nothing is deleted.",
527
528
  inputSchema: {
528
529
  type: "object",
529
530
  properties: {
@@ -1,7 +1,8 @@
1
1
  import type { AnalysisStore } from "../core/analysis-store.js";
2
2
  import { type Args, type MutationResult } from "./types.js";
3
3
  /**
4
- * Supprime des constats par identifiant et les retire de la sélection de la
5
- * revue. Un identifiant inconnu fait échouer l'appel sans rien supprimer.
4
+ * Supprime des constats par identifiant, avec les remarques du relecteur qui
5
+ * leur répondent, et les retire de la sélection de la revue. Un identifiant
6
+ * inconnu fait échouer l'appel sans rien supprimer.
6
7
  */
7
8
  export declare function deleteFindings(store: AnalysisStore, args: Args): MutationResult;
@@ -1,13 +1,15 @@
1
1
  import { AnalysisError } from "../core/errors.js";
2
2
  import { rejectUnknownFields, requireString, requireStringArray, textResult } from "./types.js";
3
3
  /**
4
- * Supprime des constats par identifiant et les retire de la sélection de la
5
- * revue. Un identifiant inconnu fait échouer l'appel sans rien supprimer.
4
+ * Supprime des constats par identifiant, avec les remarques du relecteur qui
5
+ * leur répondent, et les retire de la sélection de la revue. Un identifiant
6
+ * inconnu fait échouer l'appel sans rien supprimer.
6
7
  */
7
8
  export function deleteFindings(store, args) {
8
9
  rejectUnknownFields(args, ["analysis_id", "finding_ids"]);
9
10
  const id = requireString(args, "analysis_id");
10
11
  const ids = [...new Set(requireStringArray(args, "finding_ids", 1))];
12
+ let removedNoteCount = 0;
11
13
  const updated = store.mutate(id, "agent", (draft) => {
12
14
  const known = new Set(draft.findings.map((finding) => finding.id));
13
15
  const unknown = ids.filter((findingId) => !known.has(findingId));
@@ -18,10 +20,15 @@ export function deleteFindings(store, args) {
18
20
  const removed = new Set(ids);
19
21
  draft.findings = draft.findings.filter((finding) => !removed.has(finding.id));
20
22
  draft.review.selectedFindingIds = draft.review.selectedFindingIds.filter((findingId) => !removed.has(findingId));
23
+ const removedNotes = new Set(draft.notes.filter((note) => note.findingId !== null && removed.has(note.findingId)).map((note) => note.id));
24
+ removedNoteCount = removedNotes.size;
25
+ draft.notes = draft.notes.filter((note) => !removedNotes.has(note.id));
26
+ draft.review.selectedNoteIds = draft.review.selectedNoteIds.filter((noteId) => !removedNotes.has(noteId));
21
27
  });
28
+ const notes = removedNoteCount > 0 ? ` ${removedNoteCount} reviewer note(s) answering them were deleted too.` : "";
22
29
  return {
23
30
  result: textResult(`Deleted ${ids.length} finding(s) from "${updated.title}" (${updated.id}): ${ids.join(", ")}. ` +
24
- `${updated.findings.length} finding(s) remain.`),
31
+ `${updated.findings.length} finding(s) remain.${notes}`),
25
32
  analysisId: updated.id,
26
33
  };
27
34
  }