@rmrdeveloper/sideroom-pi 8.0.2 → 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.
@@ -0,0 +1,346 @@
1
+ import {
2
+ LANGUAGE,
3
+ type LanguageId,
4
+ RULE_SEVERITY,
5
+ type RuleId,
6
+ supportsBracedConditionals,
7
+ } from './catalog.ts';
8
+ import { maskNonCode } from './lexer.ts';
9
+ import type { AddedLine, RuleViolation } from './model.ts';
10
+
11
+ const BANNED_ALTERNATION = 'data|info|temp|tmp|result|obj|val|x';
12
+ const CONTROL_HEADER = /\b(?:if|else\s+if|for|while)\s*\(/g;
13
+ const ELSE_KEYWORD = /\belse\b/g;
14
+ const PYTHON_INLINE_SUITE =
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
+
17
+ const EMPTY_CATCH =
18
+ /\bcatch\s*(?:\([^)]*\))?\s*\{\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)?\}/g;
19
+ const SWALLOW_RETURN =
20
+ /\bcatch\s*(?:\([^)]*\))?\s*\{\s*return\s+(?:null|undefined|void\s+0)\s*;?\s*\}/g;
21
+ const PYTHON_SWALLOW =
22
+ /^[ \t]*except\b[^:\n]*:[ \t]*(?:pass|\.\.\.)[ \t]*(?:#[^\n]*)?$/gm;
23
+ const PYTHON_SWALLOW_BLOCK =
24
+ /^[ \t]*except\b[^:\n]*:[ \t]*\n[ \t]*(?:pass|\.\.\.)[ \t]*(?:#[^\n]*)?$/gm;
25
+
26
+ const TODO_COMMENT = /(?:\/\/|#|\/\*)[^\n]*\b(?:TODO|FIXME|XXX|HACK)\b/;
27
+ const COMMENTED_CODE =
28
+ /^\s*(?:\/\/|#)\s*(?:if|for|while|return|const|let|var|function|class|def|import|from|print|console|await|async)\b/;
29
+ const CODE_LIKE_COMMENT = /^\s*(?:\/\/|#).*[;{}]\s*$/;
30
+
31
+ const DEBUG_PATTERNS: readonly RegExp[] = [
32
+ /\bconsole\.(?:log|debug)\s*\(/,
33
+ /\bdebugger\b/,
34
+ /^\s*print\s*\(/,
35
+ /\bvar_dump\s*\(/,
36
+ /\bdd\s*\(/,
37
+ /\bdump\s*\(/,
38
+ /\bdbg!\s*\(/,
39
+ ];
40
+
41
+ const FUNCTION_PARAMS: readonly RegExp[] = [
42
+ /\bfunction\s+\w*\s*\(([^()]*)\)/g,
43
+ /\bdef\s+\w+\s*\(([^()]*)\)/g,
44
+ /\bfn\s+\w+\s*\(([^()]*)\)/g,
45
+ /\(([^()]*)\)\s*=>/g,
46
+ ];
47
+
48
+ export function evaluateAddedLines(
49
+ language: LanguageId,
50
+ lines: readonly AddedLine[],
51
+ ): RuleViolation[] {
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
+ }
61
+
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);
73
+ }
74
+ collectClearNames(codeLine, violations);
75
+ collectComments(added, violations);
76
+ collectDebugArtifacts(codeLine, violations);
77
+ }
78
+
79
+ collectErrorHandling(language, code, lines, violations);
80
+ return dedupe(violations);
81
+ }
82
+
83
+ function violation(
84
+ ruleId: RuleId,
85
+ message: string,
86
+ line: number,
87
+ ): RuleViolation {
88
+ return { ruleId, severity: RULE_SEVERITY[ruleId], message, line };
89
+ }
90
+
91
+ function collectBracedConditionals(
92
+ code: string,
93
+ lines: readonly AddedLine[],
94
+ violations: RuleViolation[],
95
+ ): void {
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
+ }
119
+ violations.push(
120
+ violation(
121
+ 'braced-conditionals',
122
+ '[braced-conditionals] Wrap the conditional body in braces.',
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),
146
+ ),
147
+ );
148
+ }
149
+ }
150
+
151
+ function collectPythonSuite(
152
+ added: AddedLine,
153
+ violations: RuleViolation[],
154
+ ): void {
155
+ if (PYTHON_INLINE_SUITE.test(added.text)) {
156
+ violations.push(
157
+ violation(
158
+ 'braced-conditionals',
159
+ '[braced-conditionals] Put the suite on its own indented line.',
160
+ added.line,
161
+ ),
162
+ );
163
+ }
164
+ }
165
+
166
+ function collectErrorHandling(
167
+ language: LanguageId,
168
+ code: string,
169
+ lines: readonly AddedLine[],
170
+ violations: RuleViolation[],
171
+ ): void {
172
+ if (language === LANGUAGE.python) {
173
+ collectPattern(PYTHON_SWALLOW, code, lines, violations);
174
+ collectPattern(PYTHON_SWALLOW_BLOCK, code, lines, violations);
175
+ return;
176
+ }
177
+ if (language === LANGUAGE.generic) {
178
+ return;
179
+ }
180
+ collectPattern(EMPTY_CATCH, code, lines, violations);
181
+ collectPattern(SWALLOW_RETURN, code, lines, violations);
182
+ }
183
+
184
+ function collectPattern(
185
+ pattern: RegExp,
186
+ joined: string,
187
+ lines: readonly AddedLine[],
188
+ violations: RuleViolation[],
189
+ ): void {
190
+ const matcher = new RegExp(pattern.source, pattern.flags);
191
+ for (const match of joined.matchAll(matcher)) {
192
+ const offset = match.index ?? 0;
193
+ violations.push(
194
+ violation(
195
+ 'explicit-error-handling',
196
+ '[explicit-error-handling] Do not swallow errors; rethrow with context or let them propagate.',
197
+ lineForOffset(joined, lines, offset),
198
+ ),
199
+ );
200
+ }
201
+ }
202
+
203
+ function collectClearNames(
204
+ added: AddedLine,
205
+ violations: RuleViolation[],
206
+ ): void {
207
+ const declaration = new RegExp(
208
+ `\\b(?:const|let|var|val|auto|final)\\s+(${BANNED_ALTERNATION})\\b`,
209
+ );
210
+ const assignment = new RegExp(`^\\s*(${BANNED_ALTERNATION})\\s*(?::=|=)`);
211
+ const match = declaration.exec(added.text) ?? assignment.exec(added.text);
212
+ if (match !== null) {
213
+ pushClearName(added.line, match[1], violations);
214
+ return;
215
+ }
216
+
217
+ for (const pattern of FUNCTION_PARAMS) {
218
+ const params = new RegExp(pattern.source, pattern.flags);
219
+ for (const paramMatch of added.text.matchAll(params)) {
220
+ const banned = new RegExp(`\\b(${BANNED_ALTERNATION})\\b`).exec(
221
+ paramMatch[1] ?? '',
222
+ );
223
+ if (banned !== null) {
224
+ pushClearName(added.line, banned[1], violations);
225
+ return;
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ function pushClearName(
232
+ line: number,
233
+ name: string | undefined,
234
+ violations: RuleViolation[],
235
+ ): void {
236
+ const label = name ?? 'identifier';
237
+ violations.push(
238
+ violation(
239
+ 'clear-names',
240
+ `[clear-names] Rename '${label}' to a name that reveals its role.`,
241
+ line,
242
+ ),
243
+ );
244
+ }
245
+
246
+ function collectComments(added: AddedLine, violations: RuleViolation[]): void {
247
+ if (
248
+ TODO_COMMENT.test(added.text) ||
249
+ COMMENTED_CODE.test(added.text) ||
250
+ CODE_LIKE_COMMENT.test(added.text)
251
+ ) {
252
+ violations.push(
253
+ violation(
254
+ 'comments',
255
+ '[comments] Remove stale TODO/FIXME or commented-out code; keep only non-obvious intent.',
256
+ added.line,
257
+ ),
258
+ );
259
+ }
260
+ }
261
+
262
+ function collectDebugArtifacts(
263
+ added: AddedLine,
264
+ violations: RuleViolation[],
265
+ ): void {
266
+ if (DEBUG_PATTERNS.some((pattern) => pattern.test(added.text))) {
267
+ violations.push(
268
+ violation(
269
+ 'debug-artifacts',
270
+ '[debug-artifacts] Remove debug output before finishing.',
271
+ added.line,
272
+ ),
273
+ );
274
+ }
275
+ }
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
+
320
+ function lineForOffset(
321
+ joined: string,
322
+ lines: readonly AddedLine[],
323
+ offset: number,
324
+ ): number {
325
+ let lineIndex = 0;
326
+ for (let index = 0; index < offset; index += 1) {
327
+ if (joined.charCodeAt(index) === 10) {
328
+ lineIndex += 1;
329
+ }
330
+ }
331
+ return lines[lineIndex]?.line ?? lines.at(-1)?.line ?? 0;
332
+ }
333
+
334
+ function dedupe(violations: readonly RuleViolation[]): RuleViolation[] {
335
+ const seen = new Set<string>();
336
+ const deduped: RuleViolation[] = [];
337
+ for (const item of violations) {
338
+ const key = `${item.ruleId}:${String(item.line)}:${item.message}`;
339
+ if (seen.has(key)) {
340
+ continue;
341
+ }
342
+ seen.add(key);
343
+ deduped.push(item);
344
+ }
345
+ return deduped;
346
+ }
@@ -0,0 +1,197 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import {
4
+ type ExtensionAPI,
5
+ isEditToolResult,
6
+ isToolCallEventType,
7
+ isWriteToolResult,
8
+ type ToolCallEvent,
9
+ } from '@earendil-works/pi-coding-agent';
10
+ import { languageForPath, type RuleId, SEVERITY } from './catalog.ts';
11
+ import { evaluateAddedLines } from './checks.ts';
12
+ import {
13
+ type AddedLine,
14
+ addedLines,
15
+ formatBlockReason,
16
+ formatWarnings,
17
+ type RuleViolation,
18
+ } from './model.ts';
19
+
20
+ const BLOCK_DEGRADE_AFTER = 3;
21
+ const CLEAN_RESET_AFTER = 5;
22
+
23
+ interface PendingWarnings {
24
+ readonly path: string;
25
+ readonly violations: readonly RuleViolation[];
26
+ }
27
+
28
+ export interface RulesState {
29
+ readonly fires: Map<RuleId, number>;
30
+ cleanChecks: number;
31
+ readonly pendingWarnings: Map<string, PendingWarnings>;
32
+ }
33
+
34
+ interface ExtractedMutation {
35
+ readonly path: string;
36
+ readonly language: ReturnType<typeof languageForPath>;
37
+ readonly lines: readonly AddedLine[];
38
+ }
39
+
40
+ export function createRulesState(): RulesState {
41
+ return { fires: new Map(), cleanChecks: 0, pendingWarnings: new Map() };
42
+ }
43
+
44
+ export function resetRulesState(state: RulesState): void {
45
+ state.fires.clear();
46
+ state.cleanChecks = 0;
47
+ state.pendingWarnings.clear();
48
+ }
49
+
50
+ export function registerRulesGuard(pi: ExtensionAPI, state: RulesState): void {
51
+ pi.on('tool_call', (event, ctx) => {
52
+ const mutation = extractMutation(event, ctx.cwd);
53
+ if (mutation === undefined) {
54
+ return undefined;
55
+ }
56
+ const violations = evaluateAddedLines(mutation.language, mutation.lines);
57
+ return applyRulesOutcome(
58
+ state,
59
+ event.toolCallId,
60
+ mutation.path,
61
+ decideRules(state, violations),
62
+ );
63
+ });
64
+
65
+ pi.on('tool_result', (event) => {
66
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) {
67
+ return;
68
+ }
69
+ const pending = state.pendingWarnings.get(event.toolCallId);
70
+ state.pendingWarnings.delete(event.toolCallId);
71
+ if (pending === undefined || event.isError) {
72
+ return;
73
+ }
74
+ return {
75
+ content: [
76
+ ...event.content,
77
+ {
78
+ type: 'text' as const,
79
+ text: formatWarnings(pending.path, pending.violations),
80
+ },
81
+ ],
82
+ };
83
+ });
84
+ }
85
+
86
+ export interface RulesDecision {
87
+ readonly blocking: readonly RuleViolation[];
88
+ readonly warnings: readonly RuleViolation[];
89
+ }
90
+
91
+ export function decideRules(
92
+ state: RulesState,
93
+ violations: readonly RuleViolation[],
94
+ ): RulesDecision {
95
+ return {
96
+ blocking: violations.filter(
97
+ (item) =>
98
+ item.severity === SEVERITY.block && !isDegraded(state, item.ruleId),
99
+ ),
100
+ warnings: violations.filter(
101
+ (item) =>
102
+ item.severity === SEVERITY.warn || isDegraded(state, item.ruleId),
103
+ ),
104
+ };
105
+ }
106
+
107
+ function applyRulesOutcome(
108
+ state: RulesState,
109
+ toolCallId: string,
110
+ path: string,
111
+ decision: RulesDecision,
112
+ ): { block: true; reason: string } | undefined {
113
+ if (decision.blocking.length > 0) {
114
+ for (const item of decision.blocking) {
115
+ incrementFires(state, item.ruleId);
116
+ }
117
+ return { block: true, reason: formatBlockReason(path, decision.blocking) };
118
+ }
119
+
120
+ registerCleanCheck(state);
121
+ if (decision.warnings.length > 0) {
122
+ state.pendingWarnings.set(toolCallId, {
123
+ path,
124
+ violations: decision.warnings,
125
+ });
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function extractMutation(
131
+ event: ToolCallEvent,
132
+ cwd: string,
133
+ ): ExtractedMutation | undefined {
134
+ if (isToolCallEventType('write', event)) {
135
+ return extractWrite(event.input.path, event.input.content, cwd);
136
+ }
137
+ if (isToolCallEventType('edit', event)) {
138
+ return extractEdit(event.input.path, event.input.edits);
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function extractWrite(
144
+ path: string,
145
+ content: string,
146
+ cwd: string,
147
+ ): ExtractedMutation {
148
+ const previous = readFileIfExists(resolve(cwd, path));
149
+ return {
150
+ path,
151
+ language: languageForPath(path),
152
+ lines: addedLines(previous, content),
153
+ };
154
+ }
155
+
156
+ function extractEdit(
157
+ path: string,
158
+ edits: readonly { readonly oldText: string; readonly newText: string }[],
159
+ ): ExtractedMutation {
160
+ const lines: AddedLine[] = [];
161
+ for (const edit of edits) {
162
+ lines.push(...addedLines(edit.oldText, edit.newText));
163
+ }
164
+ return { path, language: languageForPath(path), lines };
165
+ }
166
+
167
+ function readFileIfExists(path: string): string | undefined {
168
+ try {
169
+ return readFileSync(path, 'utf8');
170
+ } catch (error) {
171
+ if (isMissingFile(error)) {
172
+ return undefined;
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ function isMissingFile(error: unknown): boolean {
179
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
180
+ }
181
+
182
+ function isDegraded(state: RulesState, ruleId: RuleId): boolean {
183
+ return (state.fires.get(ruleId) ?? 0) >= BLOCK_DEGRADE_AFTER;
184
+ }
185
+
186
+ function incrementFires(state: RulesState, ruleId: RuleId): void {
187
+ state.fires.set(ruleId, (state.fires.get(ruleId) ?? 0) + 1);
188
+ }
189
+
190
+ function registerCleanCheck(state: RulesState): void {
191
+ state.cleanChecks += 1;
192
+ if (state.cleanChecks < CLEAN_RESET_AFTER) {
193
+ return;
194
+ }
195
+ state.cleanChecks = 0;
196
+ state.fires.clear();
197
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ createRulesState,
4
+ registerRulesGuard,
5
+ resetRulesState,
6
+ } from './guard.ts';
7
+
8
+ // Pi loads extensions/*/index.ts through export default.
9
+ export default function registerRules(pi: ExtensionAPI): void {
10
+ const state = createRulesState();
11
+ registerRulesGuard(pi, state);
12
+
13
+ pi.on('input', (event) => {
14
+ if (event.source === 'interactive' || event.source === 'rpc') {
15
+ resetRulesState(state);
16
+ }
17
+ });
18
+
19
+ pi.on('session_compact', () => {
20
+ resetRulesState(state);
21
+ });
22
+ }
@@ -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
+ }