@rmrdeveloper/sideroom-pi 8.1.0 → 8.1.1
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 +6 -0
- 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 +1 -1
- package/skills/sideroom-guidelines/SKILL.md +3 -1
- package/skills/sideroom-guidelines/references/languages/javascript.md +384 -0
package/README.md
CHANGED
|
@@ -250,6 +250,12 @@ Single-session scope only. Files are created lazily: nothing exists until
|
|
|
250
250
|
the first term or decision crystallises. A session with a sharper glossary
|
|
251
251
|
and zero ADRs is working as designed.
|
|
252
252
|
|
|
253
|
+
## Documentation
|
|
254
|
+
|
|
255
|
+
Maintainer reference lives in [`docs/`](docs/README.md): the
|
|
256
|
+
[architecture](docs/architecture.md) and one page per
|
|
257
|
+
[extension](docs/extensions/ask.md).
|
|
258
|
+
|
|
253
259
|
## License
|
|
254
260
|
|
|
255
261
|
MIT.
|
|
@@ -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 {
|
package/extensions/todo/index.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
2
2
|
import { Text } from '@earendil-works/pi-tui';
|
|
3
3
|
import { executeTodo, type TodoToolDetails } from './execute.ts';
|
|
4
4
|
import { registerGuardEvents } from './guards.ts';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
prepareTodoArguments,
|
|
7
|
+
TOOL_NAME,
|
|
8
|
+
TodoToolParametersSchema,
|
|
9
|
+
} from './model.ts';
|
|
6
10
|
import {
|
|
7
11
|
type BoardLifecycle,
|
|
8
12
|
commitBoard,
|
|
@@ -59,7 +63,7 @@ function registerTodoTool(
|
|
|
59
63
|
description: TODO_DESCRIPTION,
|
|
60
64
|
promptSnippet: TODO_PROMPT_SNIPPET,
|
|
61
65
|
promptGuidelines: TODO_PROMPT_GUIDELINES,
|
|
62
|
-
parameters:
|
|
66
|
+
parameters: TodoToolParametersSchema,
|
|
63
67
|
prepareArguments: prepareTodoArguments,
|
|
64
68
|
executionMode: 'sequential',
|
|
65
69
|
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
package/extensions/todo/model.ts
CHANGED
|
@@ -27,6 +27,11 @@ const TodoStatusSchema = Type.Union([
|
|
|
27
27
|
Type.Literal(TODO_STATUS.cancelled),
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
const TodoActionSchema = Type.Union([
|
|
31
|
+
Type.Literal(TODO_ACTION.propose),
|
|
32
|
+
Type.Literal(TODO_ACTION.update),
|
|
33
|
+
]);
|
|
34
|
+
|
|
30
35
|
const TodoItemSchema = Type.Object({
|
|
31
36
|
id: Type.String({ description: 'Stable identifier for this item' }),
|
|
32
37
|
content: Type.String({ description: 'Short description of the work' }),
|
|
@@ -41,28 +46,53 @@ const TodoPatchSchema = Type.Object({
|
|
|
41
46
|
),
|
|
42
47
|
});
|
|
43
48
|
|
|
49
|
+
const TodoItemsField = Type.Array(TodoItemSchema, {
|
|
50
|
+
maxItems: MAX_TODO_ITEMS,
|
|
51
|
+
description: 'Replacement board for propose, with at most 20 items',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const TodoPatchesField = Type.Array(TodoPatchSchema, {
|
|
55
|
+
minItems: 1,
|
|
56
|
+
description: 'Patches to apply for update',
|
|
57
|
+
});
|
|
58
|
+
|
|
44
59
|
const ProposeParamsSchema = Type.Object({
|
|
45
60
|
action: Type.Literal(TODO_ACTION.propose),
|
|
46
|
-
items:
|
|
47
|
-
maxItems: MAX_TODO_ITEMS,
|
|
48
|
-
description: 'Replacement board, with at most 20 items',
|
|
49
|
-
}),
|
|
61
|
+
items: TodoItemsField,
|
|
50
62
|
});
|
|
51
63
|
|
|
52
64
|
const UpdateParamsSchema = Type.Object({
|
|
53
65
|
action: Type.Literal(TODO_ACTION.update),
|
|
54
|
-
patches:
|
|
55
|
-
minItems: 1,
|
|
56
|
-
description: 'Patches to apply together',
|
|
57
|
-
}),
|
|
66
|
+
patches: TodoPatchesField,
|
|
58
67
|
});
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Strict validator and types for sideroom_todo arguments. Kept as a
|
|
71
|
+
* discriminated union so invalid action/field combinations are rejected
|
|
72
|
+
* exactly. This schema is not registered with Pi; see
|
|
73
|
+
* TodoToolParametersSchema.
|
|
74
|
+
*/
|
|
60
75
|
export const TodoParamsSchema = Type.Union([
|
|
61
76
|
ProposeParamsSchema,
|
|
62
77
|
UpdateParamsSchema,
|
|
63
78
|
]);
|
|
64
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Tool-facing schema registered with Pi. It must stay a top-level object:
|
|
82
|
+
* Claude Code silently drops every tool of an MCP server when one tool's
|
|
83
|
+
* inputSchema has a non-object top level (anyOf/oneOf/allOf), which is exactly
|
|
84
|
+
* what TodoParamsSchema emits. The discriminated union is still enforced at
|
|
85
|
+
* runtime by parseTodoParams. See docs/extensions/todo.md ("Tool schema
|
|
86
|
+
* compatibility") for the bug references and the condition to revert.
|
|
87
|
+
*/
|
|
88
|
+
export const TodoToolParametersSchema = Type.Object({
|
|
89
|
+
action: TodoActionSchema,
|
|
90
|
+
items: Type.Optional(TodoItemsField),
|
|
91
|
+
patches: Type.Optional(TodoPatchesField),
|
|
92
|
+
});
|
|
93
|
+
|
|
65
94
|
export type TodoParams = Static<typeof TodoParamsSchema>;
|
|
95
|
+
export type TodoToolParameters = Static<typeof TodoToolParametersSchema>;
|
|
66
96
|
export type TodoAction = (typeof TODO_ACTION)[keyof typeof TODO_ACTION];
|
|
67
97
|
export type TodoStatus = (typeof TODO_STATUS)[keyof typeof TODO_STATUS];
|
|
68
98
|
|
|
@@ -127,8 +157,8 @@ type JsonWireValue =
|
|
|
127
157
|
| readonly JsonWireValue[]
|
|
128
158
|
| JsonWireObject;
|
|
129
159
|
|
|
130
|
-
export function prepareTodoArguments(args: unknown):
|
|
131
|
-
return decodeTodoWireArgs(args) as
|
|
160
|
+
export function prepareTodoArguments(args: unknown): TodoToolParameters {
|
|
161
|
+
return decodeTodoWireArgs(args) as TodoToolParameters;
|
|
132
162
|
}
|
|
133
163
|
|
|
134
164
|
function decodeTodoWireArgs(args: unknown): JsonWireValue {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.1.
|
|
3
|
+
"version": "8.1.1",
|
|
4
4
|
"description": "A global Pi package with a questionnaire, work board, edited-file view, coding guidelines, rule enforcement, and a finish gate for parent agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sideroom-guidelines
|
|
3
|
-
description: "Apply Sideroom's mandatory coding workflow before implementing, refactoring, or fixing Java, Laravel/PHP, TypeScript/TSX, Python, Go, or Rust code. Load this skill and the exact language guide before mutation. Do not use for unrelated prose or domain-only discussions."
|
|
3
|
+
description: "Apply Sideroom's mandatory coding workflow before implementing, refactoring, or fixing Java, Laravel/PHP, JavaScript/JSX, TypeScript/TSX, Python, Go, or Rust code. Load this skill and the exact language guide before mutation. Do not use for unrelated prose or domain-only discussions."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: RMRdeveloper
|
|
@@ -26,6 +26,7 @@ Load this whole skill with `read`, without `offset` or `limit`, before the first
|
|
|
26
26
|
| Path | Read |
|
|
27
27
|
| ------ | ------ |
|
|
28
28
|
| `.java` | `references/languages/java.md` |
|
|
29
|
+
| `.js` `.jsx` `.mjs` `.cjs` | `references/languages/javascript.md` |
|
|
29
30
|
| `.php` | `references/languages/php-laravel.md` |
|
|
30
31
|
| `.ts` `.tsx` | `references/languages/typescript.md` |
|
|
31
32
|
| `.py` | `references/languages/python.md` |
|
|
@@ -74,6 +75,7 @@ Changed code follows every applicable rule in the loaded complete guide. The fin
|
|
|
74
75
|
## References
|
|
75
76
|
|
|
76
77
|
- `references/languages/java.md`
|
|
78
|
+
- `references/languages/javascript.md`
|
|
77
79
|
- `references/languages/php-laravel.md`
|
|
78
80
|
- `references/languages/typescript.md`
|
|
79
81
|
- `references/languages/python.md`
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# JavaScript Coding Guidelines
|
|
2
|
+
|
|
3
|
+
Rules every JavaScript change must follow.
|
|
4
|
+
|
|
5
|
+
## Index
|
|
6
|
+
|
|
7
|
+
| # | Rule | Tier |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| 1 | [Braced conditionals](#1-braced-conditionals) | mechanical |
|
|
10
|
+
| 2 | [No magic strings](#2-no-magic-strings) | mechanical |
|
|
11
|
+
| 3 | [Guard clauses](#3-guard-clauses) | structural |
|
|
12
|
+
| 4 | [Fail fast](#4-fail-fast) | structural |
|
|
13
|
+
| 5 | [Explicit error handling](#5-explicit-error-handling) | structural |
|
|
14
|
+
| 6 | [Clear names](#6-clear-names) | structural |
|
|
15
|
+
| 7 | [Command Query Separation](#7-command-query-separation-cqs) | structural |
|
|
16
|
+
| 8 | [Null/undefined handling](#8-nullundefined-handling) | structural |
|
|
17
|
+
| 9 | [Immutability by default](#9-immutability-by-default) | structural |
|
|
18
|
+
| 10 | [Validate once](#10-validate-once) | structural |
|
|
19
|
+
| 11 | [Single Responsibility](#11-single-responsibility-srp) | architectural |
|
|
20
|
+
| 12 | [DRY (real duplication only)](#12-dry-real-duplication-only) | architectural |
|
|
21
|
+
| 13 | [KISS](#13-kiss) | architectural |
|
|
22
|
+
| 14 | [YAGNI](#14-yagni) | architectural |
|
|
23
|
+
| 15 | [Law of Demeter](#15-law-of-demeter) | architectural |
|
|
24
|
+
| 16 | [Composition over inheritance](#16-composition-over-inheritance) | architectural |
|
|
25
|
+
| 17 | [Dependency direction](#17-dependency-direction) | architectural |
|
|
26
|
+
| 18 | [Testability](#18-testability-as-a-design-constraint) | architectural |
|
|
27
|
+
| 19 | [Comments](#19-comments) | architectural |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### 1. Braced conditionals
|
|
32
|
+
|
|
33
|
+
RULE: Every `if`/`else` body uses braces, even a one-line guard. Never bind a statement to a conditional by indentation alone.
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// bad
|
|
37
|
+
if (!user) return 0;
|
|
38
|
+
// good
|
|
39
|
+
if (!user) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. No magic strings
|
|
45
|
+
|
|
46
|
+
RULE: Name domain/protocol literals once (const, enum, shared contract) and reuse the name. One-off strings with no reuse and no domain meaning may stay inline.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// bad
|
|
50
|
+
if (order.status === 'pending_payment') {
|
|
51
|
+
charge(order);
|
|
52
|
+
}
|
|
53
|
+
// good
|
|
54
|
+
const ORDER_STATUS = { PENDING_PAYMENT: 'pending_payment' };
|
|
55
|
+
if (order.status === ORDER_STATUS.PENDING_PAYMENT) {
|
|
56
|
+
charge(order);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Guard clauses
|
|
61
|
+
|
|
62
|
+
RULE: Validate and exit early. Keep the happy path last, at the shallowest indent. No pyramid nesting.
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// bad
|
|
66
|
+
function discount(user) {
|
|
67
|
+
if (user) {
|
|
68
|
+
if (user.active) {
|
|
69
|
+
return user.subscribed ? 0.2 : 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
// good
|
|
75
|
+
function discount(user) {
|
|
76
|
+
if (!user || !user.active || !user.subscribed) {
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
return 0.2;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Fail fast
|
|
84
|
+
|
|
85
|
+
RULE: Invalid input, impossible state, or a broken dependency throws immediately with a clear message. No fallback chains or silent defaults.
|
|
86
|
+
WHY: This is for invariants that should never happen if upstream contracts hold — not license to re-check business rules a boundary already validated (see rule 10).
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
// bad
|
|
90
|
+
const zone = zones[order.zoneId] ?? zones.default;
|
|
91
|
+
// good
|
|
92
|
+
const zone = zones[order.zoneId];
|
|
93
|
+
if (!zone) {
|
|
94
|
+
throw new Error(`Unknown zoneId: ${order.zoneId}`);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 5. Explicit error handling
|
|
99
|
+
|
|
100
|
+
RULE: Never swallow an error in an empty or generic `catch`. Re-throw with context, or let it propagate.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// bad
|
|
104
|
+
async function loadUser(id) {
|
|
105
|
+
try {
|
|
106
|
+
return await api.getUser(id);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// good
|
|
112
|
+
async function loadUser(id) {
|
|
113
|
+
try {
|
|
114
|
+
return await api.getUser(id);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`Failed to load user ${id}`, { cause: error });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 6. Clear names
|
|
122
|
+
|
|
123
|
+
RULE: Names reveal role or domain meaning. No `data`, `info`, `temp`, `result`, `obj`, `val`, `x`. A long honest name beats a short vague one.
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// bad
|
|
127
|
+
function process(data) {
|
|
128
|
+
return data.filter((x) => x.val > 0);
|
|
129
|
+
}
|
|
130
|
+
// good
|
|
131
|
+
function getActiveSubscriptions(subscriptions) {
|
|
132
|
+
return subscriptions.filter(
|
|
133
|
+
(subscription) => subscription.remainingDays > 0,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 7. Command Query Separation (CQS)
|
|
139
|
+
|
|
140
|
+
RULE: A function either does something (side effect) or returns something (query) — never both. A "getter" must never mutate.
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
// bad
|
|
144
|
+
function getNextId(counter) {
|
|
145
|
+
counter.value += 1;
|
|
146
|
+
return counter.value;
|
|
147
|
+
}
|
|
148
|
+
// good
|
|
149
|
+
function peekNextId(counter) {
|
|
150
|
+
return counter.value + 1;
|
|
151
|
+
}
|
|
152
|
+
function incrementCounter(counter) {
|
|
153
|
+
counter.value += 1;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 8. Null/undefined handling
|
|
158
|
+
|
|
159
|
+
RULE: Pick one convention (e.g. `undefined` = not set, `null` = explicitly empty) and never mix them for the same meaning. Never overload null to mean several business states — give each state its own explicit value.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
// bad
|
|
163
|
+
function discount(user) {
|
|
164
|
+
if (!user) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!user.plan) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (user.plan.discount === 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return user.plan.discount;
|
|
174
|
+
}
|
|
175
|
+
// good
|
|
176
|
+
function discount(user) {
|
|
177
|
+
if (!user?.plan) {
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
return user.plan.discount;
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 9. Immutability by default
|
|
185
|
+
|
|
186
|
+
RULE: Return new values instead of mutating parameters. Never hide a side effect inside what looks like a pure transform.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
// bad
|
|
190
|
+
function addItem(cart, item) {
|
|
191
|
+
cart.items.push(item);
|
|
192
|
+
return cart;
|
|
193
|
+
}
|
|
194
|
+
// good
|
|
195
|
+
function addItem(cart, item) {
|
|
196
|
+
return { ...cart, items: [...cart.items, item] };
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 10. Validate once
|
|
201
|
+
|
|
202
|
+
RULE: Validate at the boundary that owns the input (schema, request DTO). Downstream code trusts that contract — no re-checking the same required/type/range rule further in.
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
// bad
|
|
206
|
+
function update(profile) {
|
|
207
|
+
if (!profile.email) {
|
|
208
|
+
throw new Error('email required');
|
|
209
|
+
}
|
|
210
|
+
repository.update(profile);
|
|
211
|
+
}
|
|
212
|
+
// good
|
|
213
|
+
function update(validatedProfile) {
|
|
214
|
+
repository.update(validatedProfile);
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 11. Single Responsibility (SRP)
|
|
219
|
+
|
|
220
|
+
RULE: A function, class, or module has one reason to change. If it validates AND transforms AND persists AND notifies, split it.
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
// bad
|
|
224
|
+
async function saveUser(data) {
|
|
225
|
+
if (!data.email) {
|
|
226
|
+
throw new Error('email required');
|
|
227
|
+
}
|
|
228
|
+
const normalized = { ...data, email: data.email.trim().toLowerCase() };
|
|
229
|
+
await db.users.insert(normalized);
|
|
230
|
+
await mailer.send(normalized.email, 'welcome');
|
|
231
|
+
}
|
|
232
|
+
// good
|
|
233
|
+
function validateUser(userInput) {
|
|
234
|
+
if (!userInput.email) {
|
|
235
|
+
throw new Error('email required');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function normalizeUser(userInput) {
|
|
239
|
+
return { ...userInput, email: userInput.email.trim().toLowerCase() };
|
|
240
|
+
}
|
|
241
|
+
async function createUser(userInput) {
|
|
242
|
+
validateUser(userInput);
|
|
243
|
+
const normalizedUser = normalizeUser(userInput);
|
|
244
|
+
await db.users.insert(normalizedUser);
|
|
245
|
+
await notifyWelcome(normalizedUser.email);
|
|
246
|
+
return normalizedUser;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### 12. DRY (real duplication only)
|
|
251
|
+
|
|
252
|
+
RULE: Extract when at least 2 of 3 hold: (a) the logic appears 3+ times, (b) a future rule change would need to touch all occurrences at once, (c) the copies represent the same domain concept. Do not extract on the first or second occurrence, or extract look-alike code from different concepts.
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
// bad — extracted after one use
|
|
256
|
+
function formatName(value) {
|
|
257
|
+
return value.trim().toUpperCase();
|
|
258
|
+
}
|
|
259
|
+
// good — the third occurrence confirms one domain concept
|
|
260
|
+
function normalizeTicketStatus(status) {
|
|
261
|
+
return status.trim().toLowerCase();
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 13. KISS
|
|
266
|
+
|
|
267
|
+
RULE: Ship the simplest solution for the current, real requirement. No configurability, hooks, or layers added "just in case."
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
// bad
|
|
271
|
+
function formatPrice(value, options = defaults) {
|
|
272
|
+
return formatter.format(value, options);
|
|
273
|
+
}
|
|
274
|
+
// good
|
|
275
|
+
function formatPrice(value) {
|
|
276
|
+
return `$${value.toFixed(2)}`;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### 14. YAGNI
|
|
281
|
+
|
|
282
|
+
RULE: Don't add fields, params, or branches for a use case that hasn't arrived. (KISS simplifies the chosen solution; YAGNI stops you building the unrequested one.)
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
// bad
|
|
286
|
+
function createInvoice(order, recurring = false, multiCurrency = false) {
|
|
287
|
+
return build(order, recurring, multiCurrency);
|
|
288
|
+
}
|
|
289
|
+
// good
|
|
290
|
+
function createInvoice(order) {
|
|
291
|
+
return { total: order.total, items: order.items };
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### 15. Law of Demeter
|
|
296
|
+
|
|
297
|
+
RULE: Talk only to immediate collaborators. Never reach through an object to grab something several levels deep inside its internal structure.
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
// bad
|
|
301
|
+
function getCityName(user) {
|
|
302
|
+
return user.address.city.name;
|
|
303
|
+
}
|
|
304
|
+
// good
|
|
305
|
+
function getCityName(user) {
|
|
306
|
+
return user.getCityName();
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 16. Composition over inheritance
|
|
311
|
+
|
|
312
|
+
RULE: Compose small, focused behaviors instead of building inheritance chains that force unrelated methods onto every subclass.
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
// bad
|
|
316
|
+
class Animal {
|
|
317
|
+
makeSound() {}
|
|
318
|
+
fly() {}
|
|
319
|
+
}
|
|
320
|
+
class Dog extends Animal {
|
|
321
|
+
makeSound() {
|
|
322
|
+
return 'Woof';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// good
|
|
326
|
+
const canBark = { makeSound: () => 'Woof' };
|
|
327
|
+
const canFly = { fly: () => 'Flying' };
|
|
328
|
+
const dog = { ...canBark };
|
|
329
|
+
const bird = { ...canBark, ...canFly };
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### 17. Dependency direction
|
|
333
|
+
|
|
334
|
+
RULE: Inner layers (domain/business logic) never import outer layers (framework, DB, HTTP). Outer layers depend inward, never the reverse.
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
// bad
|
|
338
|
+
import { MysqlConnection } from '../infra/mysql';
|
|
339
|
+
function calculateInvoiceTotal(invoiceId) {
|
|
340
|
+
const invoice = new MysqlConnection().query(
|
|
341
|
+
'SELECT * FROM invoices WHERE id = ?',
|
|
342
|
+
[invoiceId],
|
|
343
|
+
);
|
|
344
|
+
return invoice.items.reduce((sum, item) => sum + item.price, 0);
|
|
345
|
+
}
|
|
346
|
+
// good
|
|
347
|
+
function calculateInvoiceTotal(invoice) {
|
|
348
|
+
return invoice.items.reduce((sum, item) => sum + item.price, 0);
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### 18. Testability as a design constraint
|
|
353
|
+
|
|
354
|
+
RULE: Inject dependencies (clock, network, random) instead of hardcoding them. If a function is hard to test, redesign it — don't skip the test.
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
// bad
|
|
358
|
+
function isSubscriptionExpired(subscription) {
|
|
359
|
+
return subscription.expiresAt < new Date();
|
|
360
|
+
}
|
|
361
|
+
// good
|
|
362
|
+
function isSubscriptionExpired(subscription, now = new Date()) {
|
|
363
|
+
return subscription.expiresAt < now;
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 19. Comments
|
|
368
|
+
|
|
369
|
+
RULE: Comment only non-obvious intent — a trade-off, a constraint, a hazard names can't carry. Delete narration, restated logic, and stale TODOs. Test: if deleting the comment leaves the code equally clear, delete it.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
// bad
|
|
373
|
+
// increment the counter by 1
|
|
374
|
+
counter++;
|
|
375
|
+
// good
|
|
376
|
+
// Poll because the provider cannot guarantee single webhook delivery.
|
|
377
|
+
setInterval(checkPaymentStatus, 5_000);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
**When in doubt:** fail fast, keep it flat, keep it small.
|
|
383
|
+
|
|
384
|
+
**Enforcement note:** rules 1–2 are mechanical and should be backed by a JavaScript formatter/linter. Rules 3–10 are structural checks. Rules 11–19 require review judgment.
|