@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.
- package/README.md +39 -9
- package/extensions/ask/index.ts +12 -2
- package/extensions/ask/model.ts +116 -12
- package/extensions/ask/selection.ts +47 -0
- package/extensions/ask/ui.ts +141 -34
- package/extensions/done/detect.ts +103 -4
- package/extensions/guidelines/catalog.ts +1 -0
- package/extensions/rules/checks.ts +121 -19
- package/extensions/rules/lexer.ts +136 -0
- package/extensions/rules/model.ts +166 -15
- package/extensions/todo/execute.ts +2 -2
- package/extensions/todo/index.ts +6 -2
- package/extensions/todo/model.ts +40 -10
- package/package.json +27 -6
- package/skills/sideroom-grill/SKILL.md +5 -4
- package/skills/sideroom-guidelines/SKILL.md +3 -1
- package/skills/sideroom-guidelines/references/languages/javascript.md +384 -0
|
@@ -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
|
|
57
|
-
|
|
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
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
74
|
+
collectClearNames(codeLine, violations);
|
|
61
75
|
collectComments(added, violations);
|
|
62
|
-
collectDebugArtifacts(
|
|
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
|
-
|
|
92
|
+
code: string,
|
|
93
|
+
lines: readonly AddedLine[],
|
|
79
94
|
violations: RuleViolation[],
|
|
80
95
|
): void {
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
115
|
-
collectPattern(PYTHON_SWALLOW_BLOCK,
|
|
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,
|
|
122
|
-
collectPattern(SWALLOW_RETURN,
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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 =
|
|
40
|
-
if (count
|
|
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
|
|
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:
|
|
24
|
+
params: TodoToolParameters,
|
|
25
25
|
ctx: Pick<ExtensionContext, 'mode'>,
|
|
26
26
|
currentItems: readonly TodoItem[],
|
|
27
27
|
): TodoToolResult {
|