@rmrdeveloper/sideroom-pi 8.1.0 → 8.2.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.
@@ -7,6 +7,21 @@ export interface CheckCommand {
7
7
  readonly display: string;
8
8
  }
9
9
 
10
+ const SHELL_OPERATOR = {
11
+ and: '&&',
12
+ or: '||',
13
+ pipe: '|',
14
+ sequence: ';',
15
+ background: '&',
16
+ } as const;
17
+
18
+ type ShellOperator = (typeof SHELL_OPERATOR)[keyof typeof SHELL_OPERATOR];
19
+
20
+ interface ShellSegment {
21
+ readonly text: string;
22
+ readonly operatorBefore: ShellOperator | undefined;
23
+ }
24
+
10
25
  export const PACKAGE_MANAGER = {
11
26
  npm: 'npm',
12
27
  pnpm: 'pnpm',
@@ -48,14 +63,98 @@ export function detectCheckCommand(cwd: string): CheckCommand | undefined {
48
63
  }
49
64
 
50
65
  export function commandMatches(text: string, check: CheckCommand): boolean {
51
- const normalized = collapseWhitespace(text.trim());
52
66
  const target = collapseWhitespace(check.display);
67
+ const segments = splitShellCommand(text);
68
+ const hasUnsafeOperator = segments.some(
69
+ ({ operatorBefore }) =>
70
+ operatorBefore !== undefined && operatorBefore !== SHELL_OPERATOR.and,
71
+ );
72
+ if (hasUnsafeOperator) {
73
+ return false;
74
+ }
75
+
76
+ return segments.some(({ text: segmentText }) =>
77
+ simpleCommandMatches(segmentText, target),
78
+ );
79
+ }
80
+
81
+ function simpleCommandMatches(text: string, target: string): boolean {
82
+ const normalized = collapseWhitespace(text.trim());
53
83
  if (normalized === target) {
54
84
  return true;
55
85
  }
56
- return [' ', '&', '|', ';'].some((separator) =>
57
- normalized.startsWith(`${target}${separator}`),
58
- );
86
+ return normalized.startsWith(`${target} `);
87
+ }
88
+
89
+ function splitShellCommand(commandText: string): ShellSegment[] {
90
+ const segments: ShellSegment[] = [];
91
+ let segmentStart = 0;
92
+ let operatorBefore: ShellOperator | undefined;
93
+ let quote = '';
94
+
95
+ for (let index = 0; index < commandText.length; index += 1) {
96
+ const character = commandText[index] ?? '';
97
+ if (quote.length > 0) {
98
+ if (character === '\\' && quote === '"') {
99
+ index += 1;
100
+ continue;
101
+ }
102
+ if (character === quote) {
103
+ quote = '';
104
+ }
105
+ continue;
106
+ }
107
+
108
+ const startsQuote =
109
+ character === "'" || character === '"' || character === '`';
110
+ if (startsQuote) {
111
+ quote = character;
112
+ continue;
113
+ }
114
+
115
+ const operator = shellOperatorAt(commandText, index);
116
+ if (operator === undefined) {
117
+ continue;
118
+ }
119
+ segments.push({
120
+ text: commandText.slice(segmentStart, index),
121
+ operatorBefore,
122
+ });
123
+ operatorBefore = operator;
124
+ index += operator.length - 1;
125
+ segmentStart = index + 1;
126
+ }
127
+
128
+ segments.push({
129
+ text: commandText.slice(segmentStart),
130
+ operatorBefore,
131
+ });
132
+ return segments;
133
+ }
134
+
135
+ function shellOperatorAt(
136
+ commandText: string,
137
+ index: number,
138
+ ): ShellOperator | undefined {
139
+ const twoCharacterOperator = commandText.slice(index, index + 2);
140
+ if (twoCharacterOperator === SHELL_OPERATOR.and) {
141
+ return SHELL_OPERATOR.and;
142
+ }
143
+ if (twoCharacterOperator === SHELL_OPERATOR.or) {
144
+ return SHELL_OPERATOR.or;
145
+ }
146
+
147
+ const character = commandText[index];
148
+ if (character === SHELL_OPERATOR.pipe) {
149
+ return SHELL_OPERATOR.pipe;
150
+ }
151
+ if (character === SHELL_OPERATOR.sequence) {
152
+ return SHELL_OPERATOR.sequence;
153
+ }
154
+ if (character === SHELL_OPERATOR.background) {
155
+ return SHELL_OPERATOR.background;
156
+ }
157
+ return undefined;
59
158
  }
60
159
 
61
160
  function detectPackageCommand(cwd: string): CheckCommand | undefined {
@@ -21,6 +21,7 @@ export interface LanguageGuide {
21
21
  export const LANGUAGE_GUIDES: readonly LanguageGuide[] = [
22
22
  languageGuide('go.md', ['.go']),
23
23
  languageGuide('java.md', ['.java']),
24
+ languageGuide('javascript.md', ['.js', '.jsx', '.mjs', '.cjs']),
24
25
  languageGuide('php-laravel.md', ['.php']),
25
26
  languageGuide('python.md', ['.py']),
26
27
  languageGuide('rust.md', ['.rs']),
@@ -5,12 +5,12 @@ import {
5
5
  type RuleId,
6
6
  supportsBracedConditionals,
7
7
  } from './catalog.ts';
8
+ import { maskNonCode } from './lexer.ts';
8
9
  import type { AddedLine, RuleViolation } from './model.ts';
9
10
 
10
11
  const BANNED_ALTERNATION = 'data|info|temp|tmp|result|obj|val|x';
11
-
12
- const BRACED_IF = /\b(?:if|else\s+if|for|while)\s*\(.*\)\s*(?!\{)\S/;
13
- const BRACED_ELSE = /\belse\s+(?!if\b|\{)\S/;
12
+ const CONTROL_HEADER = /\b(?:if|else\s+if|for|while)\s*\(/g;
13
+ const ELSE_KEYWORD = /\belse\b/g;
14
14
  const PYTHON_INLINE_SUITE =
15
15
  /^\s*(?:if|elif|else|for|while|with|try|except|finally|def|class)\b.*:\s*(?:return|pass|raise|break|continue|yield|assert|del|import|from|global|nonlocal|print|await|lambda)\b/;
16
16
 
@@ -50,19 +50,33 @@ export function evaluateAddedLines(
50
50
  lines: readonly AddedLine[],
51
51
  ): RuleViolation[] {
52
52
  const violations: RuleViolation[] = [];
53
+ const joined = lines.map((added) => added.text).join('\n');
54
+ const code = maskNonCode(joined, language);
55
+ const codeLines = code.split('\n');
56
+ const usesBracedConditionals = supportsBracedConditionals(language);
57
+
58
+ if (usesBracedConditionals) {
59
+ collectBracedConditionals(code, lines, violations);
60
+ }
53
61
 
54
- for (const added of lines) {
55
- if (supportsBracedConditionals(language)) {
56
- collectBracedConditionals(added, violations);
57
- } else if (language === LANGUAGE.python) {
58
- collectPythonSuite(added, violations);
62
+ for (let index = 0; index < lines.length; index += 1) {
63
+ const added = lines[index];
64
+ if (added === undefined) {
65
+ continue;
66
+ }
67
+ const codeLine: AddedLine = {
68
+ line: added.line,
69
+ text: codeLines[index] ?? '',
70
+ };
71
+ if (language === LANGUAGE.python) {
72
+ collectPythonSuite(codeLine, violations);
59
73
  }
60
- collectClearNames(added, violations);
74
+ collectClearNames(codeLine, violations);
61
75
  collectComments(added, violations);
62
- collectDebugArtifacts(added, violations);
76
+ collectDebugArtifacts(codeLine, violations);
63
77
  }
64
78
 
65
- collectErrorHandling(language, lines, violations);
79
+ collectErrorHandling(language, code, lines, violations);
66
80
  return dedupe(violations);
67
81
  }
68
82
 
@@ -75,15 +89,60 @@ function violation(
75
89
  }
76
90
 
77
91
  function collectBracedConditionals(
78
- added: AddedLine,
92
+ code: string,
93
+ lines: readonly AddedLine[],
79
94
  violations: RuleViolation[],
80
95
  ): void {
81
- if (BRACED_IF.test(added.text) || BRACED_ELSE.test(added.text)) {
96
+ const controlMatcher = new RegExp(
97
+ CONTROL_HEADER.source,
98
+ CONTROL_HEADER.flags,
99
+ );
100
+ const controlMatches = code.matchAll(controlMatcher);
101
+ for (const match of controlMatches) {
102
+ const matchOffset = match.index ?? 0;
103
+ const openingOffset = code.indexOf('(', matchOffset);
104
+ if (openingOffset < 0) {
105
+ continue;
106
+ }
107
+ const closingOffset = closingParenthesisOffset(code, openingOffset);
108
+ if (closingOffset === undefined) {
109
+ continue;
110
+ }
111
+ const bodyOffset = nextCodeOffset(code, closingOffset + 1);
112
+ if (bodyOffset === undefined) {
113
+ continue;
114
+ }
115
+ const hasBlockBody = code[bodyOffset] === '{';
116
+ if (hasBlockBody) {
117
+ continue;
118
+ }
82
119
  violations.push(
83
120
  violation(
84
121
  'braced-conditionals',
85
122
  '[braced-conditionals] Wrap the conditional body in braces.',
86
- added.line,
123
+ lineForOffset(code, lines, matchOffset),
124
+ ),
125
+ );
126
+ }
127
+
128
+ const elseMatcher = new RegExp(ELSE_KEYWORD.source, ELSE_KEYWORD.flags);
129
+ const elseMatches = code.matchAll(elseMatcher);
130
+ for (const match of elseMatches) {
131
+ const matchOffset = match.index ?? 0;
132
+ const bodyOffset = nextCodeOffset(code, matchOffset + match[0].length);
133
+ if (bodyOffset === undefined) {
134
+ continue;
135
+ }
136
+ const hasBlockBody = code[bodyOffset] === '{';
137
+ const hasNestedIf = startsWithWord(code, bodyOffset, 'if');
138
+ if (hasBlockBody || hasNestedIf) {
139
+ continue;
140
+ }
141
+ violations.push(
142
+ violation(
143
+ 'braced-conditionals',
144
+ '[braced-conditionals] Wrap the conditional body in braces.',
145
+ lineForOffset(code, lines, matchOffset),
87
146
  ),
88
147
  );
89
148
  }
@@ -106,20 +165,20 @@ function collectPythonSuite(
106
165
 
107
166
  function collectErrorHandling(
108
167
  language: LanguageId,
168
+ code: string,
109
169
  lines: readonly AddedLine[],
110
170
  violations: RuleViolation[],
111
171
  ): void {
112
- const joined = lines.map((added) => added.text).join('\n');
113
172
  if (language === LANGUAGE.python) {
114
- collectPattern(PYTHON_SWALLOW, joined, lines, violations);
115
- collectPattern(PYTHON_SWALLOW_BLOCK, joined, lines, violations);
173
+ collectPattern(PYTHON_SWALLOW, code, lines, violations);
174
+ collectPattern(PYTHON_SWALLOW_BLOCK, code, lines, violations);
116
175
  return;
117
176
  }
118
177
  if (language === LANGUAGE.generic) {
119
178
  return;
120
179
  }
121
- collectPattern(EMPTY_CATCH, joined, lines, violations);
122
- collectPattern(SWALLOW_RETURN, joined, lines, violations);
180
+ collectPattern(EMPTY_CATCH, code, lines, violations);
181
+ collectPattern(SWALLOW_RETURN, code, lines, violations);
123
182
  }
124
183
 
125
184
  function collectPattern(
@@ -215,6 +274,49 @@ function collectDebugArtifacts(
215
274
  }
216
275
  }
217
276
 
277
+ function closingParenthesisOffset(
278
+ code: string,
279
+ openingOffset: number,
280
+ ): number | undefined {
281
+ let depth = 0;
282
+ for (let index = openingOffset; index < code.length; index += 1) {
283
+ const character = code[index];
284
+ if (character === '(') {
285
+ depth += 1;
286
+ continue;
287
+ }
288
+ if (character !== ')') {
289
+ continue;
290
+ }
291
+ depth -= 1;
292
+ if (depth === 0) {
293
+ return index;
294
+ }
295
+ }
296
+ return undefined;
297
+ }
298
+
299
+ function nextCodeOffset(code: string, offset: number): number | undefined {
300
+ for (let index = offset; index < code.length; index += 1) {
301
+ const character = code[index] ?? '';
302
+ const isWhitespace = /\s/.test(character);
303
+ if (!isWhitespace) {
304
+ return index;
305
+ }
306
+ }
307
+ return undefined;
308
+ }
309
+
310
+ function startsWithWord(code: string, offset: number, word: string): boolean {
311
+ const startsAtOffset = code.startsWith(word, offset);
312
+ if (!startsAtOffset) {
313
+ return false;
314
+ }
315
+ const following = code[offset + word.length] ?? '';
316
+ const isWordCharacter = /[A-Za-z0-9_$]/.test(following);
317
+ return following.length === 0 || !isWordCharacter;
318
+ }
319
+
218
320
  function lineForOffset(
219
321
  joined: string,
220
322
  lines: readonly AddedLine[],
@@ -0,0 +1,136 @@
1
+ import { LANGUAGE, type LanguageId } from './catalog.ts';
2
+
3
+ const LEXICAL_MODE = {
4
+ code: 'code',
5
+ lineComment: 'line-comment',
6
+ blockComment: 'block-comment',
7
+ string: 'string',
8
+ } as const;
9
+
10
+ type LexicalMode = (typeof LEXICAL_MODE)[keyof typeof LEXICAL_MODE];
11
+
12
+ export function maskNonCode(source: string, language: LanguageId): string {
13
+ const masked = [...source];
14
+ let mode: LexicalMode = LEXICAL_MODE.code;
15
+ let delimiter = '';
16
+
17
+ for (let index = 0; index < source.length; index += 1) {
18
+ const character = source[index] ?? '';
19
+
20
+ if (mode === LEXICAL_MODE.lineComment) {
21
+ if (character === '\n') {
22
+ mode = LEXICAL_MODE.code;
23
+ continue;
24
+ }
25
+ maskCharacter(masked, index);
26
+ continue;
27
+ }
28
+
29
+ if (mode === LEXICAL_MODE.blockComment) {
30
+ const closesComment = character === '*' && source[index + 1] === '/';
31
+ if (closesComment) {
32
+ maskCharacter(masked, index);
33
+ maskCharacter(masked, index + 1);
34
+ index += 1;
35
+ mode = LEXICAL_MODE.code;
36
+ continue;
37
+ }
38
+ maskCharacter(masked, index);
39
+ continue;
40
+ }
41
+
42
+ if (mode === LEXICAL_MODE.string) {
43
+ if (character === '\\') {
44
+ maskCharacter(masked, index);
45
+ if (source[index + 1] !== undefined) {
46
+ maskCharacter(masked, index + 1);
47
+ index += 1;
48
+ }
49
+ continue;
50
+ }
51
+ const closesString = source.startsWith(delimiter, index);
52
+ if (closesString) {
53
+ for (let offset = 0; offset < delimiter.length; offset += 1) {
54
+ maskCharacter(masked, index + offset);
55
+ }
56
+ index += delimiter.length - 1;
57
+ delimiter = '';
58
+ mode = LEXICAL_MODE.code;
59
+ continue;
60
+ }
61
+ if (delimiter.length === 1 && character === '\n') {
62
+ mode = LEXICAL_MODE.code;
63
+ continue;
64
+ }
65
+ maskCharacter(masked, index);
66
+ continue;
67
+ }
68
+
69
+ const startsLineComment = character === '/' && source[index + 1] === '/';
70
+ if (startsLineComment) {
71
+ maskCharacter(masked, index);
72
+ maskCharacter(masked, index + 1);
73
+ index += 1;
74
+ mode = LEXICAL_MODE.lineComment;
75
+ continue;
76
+ }
77
+
78
+ const startsBlockComment = character === '/' && source[index + 1] === '*';
79
+ if (startsBlockComment) {
80
+ maskCharacter(masked, index);
81
+ maskCharacter(masked, index + 1);
82
+ index += 1;
83
+ mode = LEXICAL_MODE.blockComment;
84
+ continue;
85
+ }
86
+
87
+ const startsHashComment =
88
+ character === '#' && supportsHashComments(language);
89
+ if (startsHashComment) {
90
+ maskCharacter(masked, index);
91
+ mode = LEXICAL_MODE.lineComment;
92
+ continue;
93
+ }
94
+
95
+ const startsString =
96
+ character === "'" || character === '"' || character === '`';
97
+ if (startsString) {
98
+ delimiter = stringDelimiter(source, index, language, character);
99
+ for (let offset = 0; offset < delimiter.length; offset += 1) {
100
+ maskCharacter(masked, index + offset);
101
+ }
102
+ index += delimiter.length - 1;
103
+ mode = LEXICAL_MODE.string;
104
+ }
105
+ }
106
+
107
+ return masked.join('');
108
+ }
109
+
110
+ function stringDelimiter(
111
+ source: string,
112
+ index: number,
113
+ language: LanguageId,
114
+ quote: string,
115
+ ): string {
116
+ const tripleQuote = `${quote}${quote}${quote}`;
117
+ const isPythonTripleQuote =
118
+ language === LANGUAGE.python && source.startsWith(tripleQuote, index);
119
+ if (isPythonTripleQuote) {
120
+ return tripleQuote;
121
+ }
122
+ return quote;
123
+ }
124
+
125
+ function supportsHashComments(language: LanguageId): boolean {
126
+ return language === LANGUAGE.python || language === LANGUAGE.php;
127
+ }
128
+
129
+ function maskCharacter(masked: string[], index: number): void {
130
+ const character = masked[index];
131
+ const shouldMask =
132
+ character !== undefined && character !== '\n' && character !== '\r';
133
+ if (shouldMask) {
134
+ masked[index] = ' ';
135
+ }
136
+ }
@@ -17,36 +17,187 @@ export function addedLines(
17
17
  next: string,
18
18
  ): AddedLine[] {
19
19
  const nextLines = next.split('\n');
20
- const added: AddedLine[] = [];
21
- const remaining = new Map<string, number>();
22
-
23
- if (previous !== undefined) {
24
- for (const text of previous.split('\n')) {
25
- const key = text.trim();
26
- if (key.length === 0) {
27
- continue;
28
- }
29
- remaining.set(key, (remaining.get(key) ?? 0) + 1);
30
- }
20
+ if (previous === undefined) {
21
+ return nonBlankLines(nextLines);
31
22
  }
32
23
 
33
- for (let index = 0; index < nextLines.length; index += 1) {
24
+ const previousLines = previous.split('\n');
25
+ const insertedIndices = insertedLineIndices(previousLines, nextLines);
26
+ const availableAdditions = additionCounts(previousLines, nextLines);
27
+ const added: AddedLine[] = [];
28
+ const orderedIndices = [...insertedIndices].sort(
29
+ (left, right) => left - right,
30
+ );
31
+
32
+ for (const index of orderedIndices) {
34
33
  const text = nextLines[index] ?? '';
35
34
  const key = text.trim();
36
35
  if (key.length === 0) {
37
36
  continue;
38
37
  }
39
- const count = remaining.get(key) ?? 0;
40
- if (count > 0) {
41
- remaining.set(key, count - 1);
38
+ const count = availableAdditions.get(key) ?? 0;
39
+ if (count === 0) {
42
40
  continue;
43
41
  }
42
+ availableAdditions.set(key, count - 1);
44
43
  added.push({ line: index + 1, text });
45
44
  }
46
45
 
47
46
  return added;
48
47
  }
49
48
 
49
+ function nonBlankLines(lines: readonly string[]): AddedLine[] {
50
+ const added: AddedLine[] = [];
51
+ for (let index = 0; index < lines.length; index += 1) {
52
+ const text = lines[index] ?? '';
53
+ const trimmedText = text.trim();
54
+ if (trimmedText.length === 0) {
55
+ continue;
56
+ }
57
+ added.push({ line: index + 1, text });
58
+ }
59
+ return added;
60
+ }
61
+
62
+ function insertedLineIndices(
63
+ previousLines: readonly string[],
64
+ nextLines: readonly string[],
65
+ ): number[] {
66
+ const previousKeys = previousLines.map((line) => line.trim());
67
+ const nextKeys = nextLines.map((line) => line.trim());
68
+ const trace: Map<number, number>[] = [];
69
+ const frontier = new Map<number, number>([[1, 0]]);
70
+ const maximumDistance = previousLines.length + nextLines.length;
71
+
72
+ for (let distance = 0; distance <= maximumDistance; distance += 1) {
73
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
74
+ const down = frontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
75
+ const right =
76
+ (frontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY) + 1;
77
+ let previousIndex: number;
78
+ const shouldMoveDown =
79
+ diagonal === -distance || (diagonal !== distance && down > right);
80
+ if (shouldMoveDown) {
81
+ previousIndex = down;
82
+ } else {
83
+ previousIndex = right;
84
+ }
85
+ const hasFiniteIndex = Number.isFinite(previousIndex);
86
+ if (!hasFiniteIndex) {
87
+ previousIndex = 0;
88
+ }
89
+
90
+ let nextIndex = previousIndex - diagonal;
91
+ while (
92
+ previousIndex < previousKeys.length &&
93
+ nextIndex < nextKeys.length &&
94
+ previousKeys[previousIndex] === nextKeys[nextIndex]
95
+ ) {
96
+ previousIndex += 1;
97
+ nextIndex += 1;
98
+ }
99
+ frontier.set(diagonal, previousIndex);
100
+
101
+ if (
102
+ previousIndex >= previousKeys.length &&
103
+ nextIndex >= nextKeys.length
104
+ ) {
105
+ trace.push(new Map(frontier));
106
+ return backtrackInsertedIndices(
107
+ trace,
108
+ distance,
109
+ previousKeys,
110
+ nextKeys,
111
+ );
112
+ }
113
+ }
114
+ trace.push(new Map(frontier));
115
+ }
116
+
117
+ throw new Error('Failed to compute the line diff.');
118
+ }
119
+
120
+ function backtrackInsertedIndices(
121
+ trace: readonly Map<number, number>[],
122
+ distance: number,
123
+ previousKeys: readonly string[],
124
+ nextKeys: readonly string[],
125
+ ): number[] {
126
+ let previousIndex = previousKeys.length;
127
+ let nextIndex = nextKeys.length;
128
+ const insertedIndices: number[] = [];
129
+
130
+ for (
131
+ let currentDistance = distance;
132
+ currentDistance > 0;
133
+ currentDistance -= 1
134
+ ) {
135
+ const previousFrontier = trace[currentDistance - 1];
136
+ if (previousFrontier === undefined) {
137
+ throw new Error('Line diff trace is incomplete.');
138
+ }
139
+
140
+ const diagonal = previousIndex - nextIndex;
141
+ const down = previousFrontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
142
+ const right =
143
+ (previousFrontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY) + 1;
144
+ const shouldMoveDown =
145
+ diagonal === -currentDistance ||
146
+ (diagonal !== currentDistance && down > right);
147
+ const previousDiagonal = shouldMoveDown ? diagonal + 1 : diagonal - 1;
148
+ const previousOldIndex = previousFrontier.get(previousDiagonal);
149
+ if (previousOldIndex === undefined) {
150
+ throw new Error('Line diff trace has an invalid diagonal.');
151
+ }
152
+ const previousNextIndex = previousOldIndex - previousDiagonal;
153
+
154
+ while (
155
+ previousIndex > previousOldIndex &&
156
+ nextIndex > previousNextIndex &&
157
+ previousKeys[previousIndex - 1] === nextKeys[nextIndex - 1]
158
+ ) {
159
+ previousIndex -= 1;
160
+ nextIndex -= 1;
161
+ }
162
+
163
+ if (previousIndex === previousOldIndex) {
164
+ nextIndex -= 1;
165
+ insertedIndices.push(nextIndex);
166
+ } else {
167
+ previousIndex -= 1;
168
+ }
169
+ }
170
+
171
+ return insertedIndices;
172
+ }
173
+
174
+ function additionCounts(
175
+ previousLines: readonly string[],
176
+ nextLines: readonly string[],
177
+ ): Map<string, number> {
178
+ const previousCounts = countLineKeys(previousLines);
179
+ const nextCounts = countLineKeys(nextLines);
180
+ const additions = new Map<string, number>();
181
+
182
+ for (const [key, nextCount] of nextCounts) {
183
+ const previousCount = previousCounts.get(key) ?? 0;
184
+ additions.set(key, Math.max(nextCount - previousCount, 0));
185
+ }
186
+ return additions;
187
+ }
188
+
189
+ function countLineKeys(lines: readonly string[]): Map<string, number> {
190
+ const counts = new Map<string, number>();
191
+ for (const line of lines) {
192
+ const key = line.trim();
193
+ if (key.length === 0) {
194
+ continue;
195
+ }
196
+ counts.set(key, (counts.get(key) ?? 0) + 1);
197
+ }
198
+ return counts;
199
+ }
200
+
50
201
  export function formatBlockReason(
51
202
  path: string,
52
203
  violations: readonly RuleViolation[],
@@ -5,7 +5,7 @@ import {
5
5
  TODO_ACTION,
6
6
  type TodoAction,
7
7
  type TodoItem,
8
- type TodoParams,
8
+ type TodoToolParameters,
9
9
  UI_UNAVAILABLE,
10
10
  } from './model.ts';
11
11
 
@@ -21,7 +21,7 @@ export interface TodoToolResult {
21
21
  }
22
22
 
23
23
  export function executeTodo(
24
- params: TodoParams,
24
+ params: TodoToolParameters,
25
25
  ctx: Pick<ExtensionContext, 'mode'>,
26
26
  currentItems: readonly TodoItem[],
27
27
  ): TodoToolResult {