imdone-core 2.1.5 → 2.1.10

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.
@@ -1,11 +1,161 @@
1
1
  import { Eta } from "eta"
2
2
  import uniqid from 'uniqid';
3
+ import { logger } from '../logger.js';
3
4
 
4
5
  function removeDisplayComments(description) {
5
- return description.replace(/<!--\s*\[([\s\S]*?)\]\s*-->/g, '$1');
6
+ const startTime = Date.now();
7
+ logger.debug(`[INSTRUMENT] removeDisplayComments START - contentLength: ${description?.length || 0}`);
8
+
9
+ // State machine implementation to avoid catastrophic backtracking
10
+ // Looking for pattern: <!-- [ content ] -->
11
+ let result = '';
12
+ const len = description.length;
13
+ let i = 0;
14
+ let iterations = 0;
15
+
16
+ while (i < len) {
17
+ iterations++;
18
+ if (iterations % 10000 === 0) {
19
+ logger.debug(`[INSTRUMENT] removeDisplayComments - iteration ${iterations}, position ${i}/${len}`);
20
+ }
21
+ // Check for HTML comment start: <!--
22
+ if (i <= len - 4 &&
23
+ description[i] === '<' &&
24
+ description[i + 1] === '!' &&
25
+ description[i + 2] === '-' &&
26
+ description[i + 3] === '-') {
27
+
28
+ const commentStart = i;
29
+ i += 4; // Skip <!--
30
+
31
+ // Skip whitespace
32
+ while (i < len && /\s/.test(description[i])) {
33
+ i++;
34
+ }
35
+
36
+ // Check for opening bracket
37
+ if (i < len && description[i] === '[') {
38
+ i++; // Skip [
39
+ const contentStart = i;
40
+
41
+ // Find closing bracket followed by optional whitespace and -->
42
+ while (i < len) {
43
+ if (description[i] === ']') {
44
+ const contentEnd = i;
45
+ i++; // Skip ]
46
+
47
+ // Skip whitespace
48
+ while (i < len && /\s/.test(description[i])) {
49
+ i++;
50
+ }
51
+
52
+ // Check for comment end: -->
53
+ if (i <= len - 3 &&
54
+ description[i] === '-' &&
55
+ description[i + 1] === '-' &&
56
+ description[i + 2] === '>') {
57
+ // Found complete pattern - extract content
58
+ result += description.substring(contentStart, contentEnd);
59
+ i += 3; // Skip -->
60
+ break;
61
+ }
62
+ }
63
+ i++;
64
+ }
65
+
66
+ // If we didn't find the complete pattern, include what we had
67
+ if (i >= len) {
68
+ result += description.substring(commentStart, i);
69
+ }
70
+ continue;
71
+ }
72
+
73
+ // No opening bracket after <!--, include the comment start and continue
74
+ result += description.substring(commentStart, i);
75
+ continue;
76
+ }
77
+
78
+ result += description[i];
79
+ i++;
80
+ }
81
+
82
+ const elapsed = Date.now() - startTime;
83
+ logger.debug(`[INSTRUMENT] removeDisplayComments END - elapsed: ${elapsed}ms, iterations: ${iterations}`);
84
+ return result;
6
85
  }
7
86
 
8
- function escapeCodeBlocks(content) {
87
+ function escapeCodeBlocks(content, useRegex = false) {
88
+ if (useRegex) {
89
+ // Old regex-based implementation (can cause catastrophic backtracking)
90
+ // Kept for testing/comparison purposes only
91
+ return _escapeCodeBlocksRegex(content);
92
+ }
93
+
94
+ // New state machine implementation (safe from catastrophic backtracking)
95
+ let codeBlocks = [];
96
+ let result = '';
97
+ const len = content.length;
98
+ let i = 0;
99
+
100
+ while (i < len) {
101
+ // Check for code block (```)
102
+ if (content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`') {
103
+ const start = i;
104
+ i += 3; // Skip opening ```
105
+
106
+ // Find closing ```
107
+ while (i < len - 2) {
108
+ if (content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`') {
109
+ i += 3; // Include closing ```
110
+ const match = content.substring(start, i);
111
+ const key = `__CODE_BLOCK_${codeBlocks.length}__`;
112
+ codeBlocks.push(match);
113
+ result += key;
114
+ break;
115
+ }
116
+ i++;
117
+ }
118
+ // If we didn't find a closing ```, include what we have
119
+ if (i >= len - 2 && content[i - 3] !== '`') {
120
+ result += content.substring(start, i);
121
+ }
122
+ continue;
123
+ }
124
+
125
+ // Check for inline code (`)
126
+ if (content[i] === '`') {
127
+ const start = i;
128
+ i++; // Skip opening `
129
+
130
+ // Find closing `
131
+ while (i < len) {
132
+ if (content[i] === '`') {
133
+ i++; // Include closing `
134
+ const match = content.substring(start, i);
135
+ const key = `__CODE_BLOCK_${codeBlocks.length}__`;
136
+ codeBlocks.push(match);
137
+ result += key;
138
+ break;
139
+ }
140
+ i++;
141
+ }
142
+ // If we didn't find a closing `, include what we have
143
+ if (i >= len && content[i - 1] !== '`') {
144
+ result += content.substring(start, i);
145
+ }
146
+ continue;
147
+ }
148
+
149
+ result += content[i];
150
+ i++;
151
+ }
152
+
153
+ return { content: result, codeBlocks };
154
+ }
155
+
156
+ function _escapeCodeBlocksRegex(content) {
157
+ // OLD IMPLEMENTATION - DO NOT USE IN PRODUCTION
158
+ // This regex can cause catastrophic backtracking with nested backticks
9
159
  const codeBlockRegex = /(```[\s\S]*?```|`[^`]*`)/g;
10
160
  let codeBlocks = [];
11
161
 
@@ -28,41 +178,105 @@ function restoreCodeBlocks(content, codeBlocks) {
28
178
  }
29
179
 
30
180
  /**
31
- * Interpolates the content with the provided data.
181
+ * Interpolates the content with the provided data using state machine parsing.
32
182
  * @param {string} content - The content to interpolate.
33
183
  * @param {object} data - The data to use for interpolation.
34
184
  * @param {object} opts - The options for interpolation.
35
185
  * @returns {object} - The interpolated content, whether there was interpolation, and error tokens.
36
186
  */
37
187
  export function interpolate(content, data, opts = { tags: ['${', '}'] }) {
188
+ const startTime = Date.now();
189
+ logger.debug(`[INSTRUMENT] interpolate START - contentLength: ${content?.length || 0}, tags: ${JSON.stringify(opts.tags)}`);
190
+
38
191
  const errorTokens = {};
39
192
  const { tags } = opts;
40
- // /(?<![`\\])\${([\s\S]+?)}/g
41
- const regex = new RegExp(`(?<![\`\\\\])\\${tags[0]}([\\s\\S]+?)${tags[1]}`, 'g');
193
+ const [startTag, endTag] = tags;
42
194
  let hasInterpolation = false;
43
195
 
44
- content = content.replace(regex, (match, p1) => {
45
- try {
46
- const eta = new Eta({ tags, useWith: true, autoEscape: false });
47
- const [startTag, endTag] = opts.tags;
48
- const template = `${startTag}= ${p1} ${endTag}`;
49
- const result = eta.renderString(template, data);
50
- if (result !== match) {
51
- hasInterpolation = true;
196
+ // State machine implementation to avoid catastrophic backtracking
197
+ let result = '';
198
+ const len = content.length;
199
+ let i = 0;
200
+ let iterations = 0;
201
+
202
+ while (i < len) {
203
+ iterations++;
204
+ if (iterations % 10000 === 0) {
205
+ logger.debug(`[INSTRUMENT] interpolate - iteration ${iterations}, position ${i}/${len}, elapsed: ${Date.now() - startTime}ms`);
206
+ }
207
+
208
+ // Check if we're at a potential start tag
209
+ // Skip if preceded by backtick or backslash
210
+ const prevChar = i > 0 ? content[i - 1] : '';
211
+ const isEscaped = prevChar === '`' || prevChar === '\\';
212
+
213
+ // Check for start tag match
214
+ let startTagMatches = !isEscaped && true;
215
+ for (let j = 0; j < startTag.length && startTagMatches; j++) {
216
+ if (i + j >= len || content[i + j] !== startTag[j]) {
217
+ startTagMatches = false;
52
218
  }
53
- return result || '';
54
- } catch (e) {
55
- const token = `__ERROR_TOKEN_${uniqid()}__`;
56
- errorTokens[token] = match;
57
- return token || '';
58
219
  }
59
- });
60
220
 
61
- return { content, hasInterpolation, errorTokens };
221
+ if (startTagMatches) {
222
+ // Found start tag - look for matching end tag
223
+ const matchStart = i;
224
+ i += startTag.length;
225
+ const contentStart = i;
226
+
227
+ // Find the end tag
228
+ let foundEnd = false;
229
+ while (i < len) {
230
+ let endTagMatches = true;
231
+ for (let j = 0; j < endTag.length && endTagMatches; j++) {
232
+ if (i + j >= len || content[i + j] !== endTag[j]) {
233
+ endTagMatches = false;
234
+ }
235
+ }
236
+
237
+ if (endTagMatches) {
238
+ // Found end tag
239
+ const templateContent = content.substring(contentStart, i);
240
+ i += endTag.length;
241
+ foundEnd = true;
242
+
243
+ // Try to interpolate
244
+ try {
245
+ const eta = new Eta({ tags, useWith: true, autoEscape: false });
246
+ const template = `${startTag}= ${templateContent} ${endTag}`;
247
+ const interpolated = eta.renderString(template, data);
248
+ result += interpolated || '';
249
+ hasInterpolation = true;
250
+ } catch (e) {
251
+ // On error, preserve the original match with an error token
252
+ const token = `__ERROR_TOKEN_${uniqid()}__`;
253
+ const originalMatch = content.substring(matchStart, i);
254
+ errorTokens[token] = originalMatch;
255
+ result += token;
256
+ }
257
+ break;
258
+ }
259
+ i++;
260
+ }
261
+
262
+ // If no end tag found, include the start tag and continue
263
+ if (!foundEnd) {
264
+ result += content.substring(matchStart, i);
265
+ }
266
+ continue;
267
+ }
268
+
269
+ result += content[i];
270
+ i++;
271
+ }
272
+
273
+ const elapsed = Date.now() - startTime;
274
+ logger.debug(`[INSTRUMENT] interpolate END - elapsed: ${elapsed}ms, iterations: ${iterations}, hasInterpolation: ${hasInterpolation}`);
275
+ return { content: result, hasInterpolation, errorTokens };
62
276
  }
63
277
 
64
278
  export function encodeMarkdownLinks(content) {
65
- return content.replace(/(?<!!)\[(.*?)\]\((.*?)( ".*?")?\)/g, (match, p1, p2, p3) => {
279
+ return content.replace(/(?<!!)\[(.*?)\]\((.*?)( ".*?")?\)/g, (_match, p1, p2, p3) => {
66
280
  // URI encode file links
67
281
  const title = p3 || '';
68
282
  const encodedUrl = p2.replace(/\s/g, '%20').replace(/\)/g, '%29').replace(/\(/g, '%28');
@@ -78,17 +292,29 @@ export function encodeMarkdownLinks(content) {
78
292
  }
79
293
 
80
294
  export function format(content, data, mustache) {
295
+ const formatStart = Date.now();
296
+ logger.debug(`[INSTRUMENT] format START - contentLength: ${content?.length || 0}, mustache: ${mustache}`);
297
+
81
298
  const errorTokens = {};
82
299
 
83
- if (mustache) content = removeDisplayComments(content);
300
+ if (mustache) {
301
+ logger.debug(`[INSTRUMENT] format - calling removeDisplayComments`);
302
+ content = removeDisplayComments(content);
303
+ }
84
304
 
85
305
  // Escape code blocks before processing interpolation
306
+ logger.debug(`[INSTRUMENT] format - calling escapeCodeBlocks`);
307
+ const escapeStart = Date.now();
86
308
  const { content: escapedContent, codeBlocks } = escapeCodeBlocks(content);
309
+ logger.debug(`[INSTRUMENT] format - escapeCodeBlocks completed in ${Date.now() - escapeStart}ms`);
87
310
 
88
311
  let result = escapedContent;
89
312
  let hasInterpolation = true;
313
+ let interpolationRound = 0;
90
314
 
91
315
  while (hasInterpolation) {
316
+ interpolationRound++;
317
+ logger.debug(`[INSTRUMENT] format - interpolation round ${interpolationRound} with tags ['${'${'}', '}']`);
92
318
  const interpolationResult = interpolate(result, data, { tags: ['${', '}'] });
93
319
  result = interpolationResult.content;
94
320
  hasInterpolation = interpolationResult.hasInterpolation;
@@ -97,7 +323,10 @@ export function format(content, data, mustache) {
97
323
 
98
324
  if (mustache) {
99
325
  hasInterpolation = true;
326
+ interpolationRound = 0;
100
327
  while (hasInterpolation) {
328
+ interpolationRound++;
329
+ logger.debug(`[INSTRUMENT] format - mustache interpolation round ${interpolationRound} with tags ['{{', '}}']`);
101
330
  const interpolationResult = interpolate(result, data, { tags: ['{{', '}}'] });
102
331
  result = interpolationResult.content;
103
332
  hasInterpolation = interpolationResult.hasInterpolation;
@@ -105,6 +334,7 @@ export function format(content, data, mustache) {
105
334
  }
106
335
  }
107
336
 
337
+ logger.debug(`[INSTRUMENT] format - calling restoreCodeBlocks`);
108
338
  result = restoreCodeBlocks(result, codeBlocks);
109
339
 
110
340
  // Restore error tokens
@@ -112,5 +342,7 @@ export function format(content, data, mustache) {
112
342
  result = result.replace(new RegExp(token, 'g'), errorTokens[token]);
113
343
  });
114
344
 
345
+ const elapsed = Date.now() - formatStart;
346
+ logger.debug(`[INSTRUMENT] format END - elapsed: ${elapsed}ms`);
115
347
  return result;
116
348
  }
package/lib/file.js CHANGED
@@ -535,16 +535,38 @@ export class File extends Emitter {
535
535
  }
536
536
  }
537
537
  extractTasks (config) {
538
+ // INSTRUMENTATION: Track timing for hang investigation
539
+ const startTime = Date.now()
540
+ log(`[INSTRUMENT] File.extractTasks START for ${this.path}`)
541
+
538
542
  this.tasks = []
539
543
  if (this.isMarkDownFile()) {
540
544
  const data = this.parseFrontMatter(config)
541
- if (data.imdone_ignore || data['kanban-plugin']) return this
545
+ if (data.imdone_ignore || data['kanban-plugin']) {
546
+ log(`[INSTRUMENT] File.extractTasks END (ignored) - ${Date.now() - startTime}ms`)
547
+ return this
548
+ }
542
549
  }
550
+
551
+ const frontMatterTime = Date.now()
552
+ log(`[INSTRUMENT] Front matter check took ${frontMatterTime - startTime}ms`)
553
+
543
554
  if (this.isCodeFile()) {
555
+ log(`[INSTRUMENT] About to extractTasksInCodeFile`)
544
556
  this.extractTasksInCodeFile(config)
557
+ log(`[INSTRUMENT] extractTasksInCodeFile took ${Date.now() - frontMatterTime}ms`)
545
558
  } else {
559
+ log(`[INSTRUMENT] About to extractTasksInNonCodeFile`)
546
560
  this.extractTasksInNonCodeFile(config)
561
+ log(`[INSTRUMENT] extractTasksInNonCodeFile took ${Date.now() - frontMatterTime}ms`)
547
562
  }
563
+
564
+ const totalTime = Date.now() - startTime
565
+ if (totalTime > 1000) {
566
+ log(`[INSTRUMENT] ⚠️ SLOW File.extractTasks: ${this.path} took ${totalTime}ms`)
567
+ }
568
+ log(`[INSTRUMENT] File.extractTasks END - total ${totalTime}ms`)
569
+
548
570
  return this
549
571
  }
550
572
 
@@ -560,20 +582,62 @@ export class File extends Emitter {
560
582
  // order:40
561
583
  // -->
562
584
  extractTasksInCodeFile (config) {
585
+ // INSTRUMENTATION: Track regex iterations
586
+ const startTime = Date.now()
563
587
  var self = this
564
588
  var commentRegex = this.getCodeCommentRegex()
589
+ log(`[INSTRUMENT] getCodeCommentRegex took ${Date.now() - startTime}ms`)
590
+
565
591
  var result
592
+ var iterationCount = 0
593
+ const contentLength = self.getContent()?.length || 0
594
+ log(`[INSTRUMENT] Starting regex loop, content length: ${contentLength}`)
595
+
566
596
  while ((result = commentRegex.exec(self.getContent())) !== null) {
597
+ iterationCount++
598
+ if (iterationCount % 100 === 0) {
599
+ log(`[INSTRUMENT] Regex iteration ${iterationCount}, elapsed: ${Date.now() - startTime}ms`)
600
+ }
601
+
567
602
  var comment = result[0]
568
603
  var commentStart = result.index
569
604
  self.extractCodeStyleTasks(config, commentStart, comment)
605
+
606
+ // Safety check for infinite loop
607
+ if (iterationCount > 10000) {
608
+ log(`[INSTRUMENT] ⚠️ WARNING: Exceeded 10000 regex iterations, breaking loop`)
609
+ break
610
+ }
570
611
  }
612
+
613
+ const totalTime = Date.now() - startTime
614
+ log(`[INSTRUMENT] extractTasksInCodeFile END - ${iterationCount} iterations, ${totalTime}ms`)
571
615
  }
572
616
 
573
617
  extractTasksInNonCodeFile (config) {
574
- this.extractHashStyleTasks(config, 0, this.getContent())
575
- this.extractLinkStyleTasks(config, 0, this.getContent())
576
- this.extractCheckboxTasks(config, 0, this.getContent())
618
+ // INSTRUMENTATION: Track each extraction method
619
+ const startTime = Date.now()
620
+ const content = this.getContent()
621
+ const contentLength = content?.length || 0
622
+ log(`[INSTRUMENT] extractTasksInNonCodeFile START, content length: ${contentLength}`)
623
+
624
+ log(`[INSTRUMENT] About to extractHashStyleTasks`)
625
+ this.extractHashStyleTasks(config, 0, content)
626
+ const hashTime = Date.now()
627
+ log(`[INSTRUMENT] extractHashStyleTasks took ${hashTime - startTime}ms`)
628
+
629
+ log(`[INSTRUMENT] About to extractLinkStyleTasks`)
630
+ this.extractLinkStyleTasks(config, 0, content)
631
+ const linkTime = Date.now()
632
+ log(`[INSTRUMENT] extractLinkStyleTasks took ${linkTime - hashTime}ms`)
633
+
634
+ log(`[INSTRUMENT] About to extractCheckboxTasks`)
635
+ this.extractCheckboxTasks(config, 0, content)
636
+ const checkboxTime = Date.now()
637
+ log(`[INSTRUMENT] extractCheckboxTasks took ${checkboxTime - linkTime}ms`)
638
+
639
+ const totalTime = Date.now() - startTime
640
+ log(`[INSTRUMENT] extractTasksInNonCodeFile END - total ${totalTime}ms`)
577
641
  }
578
642
 
579
643
  deleteBlankLine (lineNo) {
@@ -1212,8 +1276,10 @@ export class File extends Emitter {
1212
1276
  if (lang.block) {
1213
1277
  var start = escapeRegExp(lang.block.start)
1214
1278
  var end = escapeRegExp(lang.block.end)
1215
- //
1216
- reString = reString + '|(?<!["\'`])' + start + '(.|[\\r\\n])*?' + end
1279
+ // Use [^] instead of (.|[\r\n]) and remove lazy quantifier for better performance
1280
+ // [^] matches any character including newlines without the overhead of alternation
1281
+ // Using greedy match with proper end delimiter is safer than lazy quantifier
1282
+ reString = reString + '|(?<!["\'`])' + start + '[^]*?' + end
1217
1283
  }
1218
1284
 
1219
1285
  return new RegExp(reString, 'gmi')
@@ -1364,7 +1430,62 @@ export class File extends Emitter {
1364
1430
  return Task.isResultInMarkdownCode(positions, pos)
1365
1431
  }
1366
1432
 
1367
- getMarkdownCodePositions (text) {
1433
+ getMarkdownCodePositions (text, useRegex = false) {
1434
+ if (useRegex) {
1435
+ // Old regex-based implementation (can cause catastrophic backtracking)
1436
+ // Kept for testing/comparison purposes only
1437
+ return this._getMarkdownCodePositionsRegex(text);
1438
+ }
1439
+
1440
+ // New state machine implementation (safe from catastrophic backtracking)
1441
+ const positions = [];
1442
+ const len = text.length;
1443
+ let i = 0;
1444
+
1445
+ while (i < len) {
1446
+ // Check for code block (```)
1447
+ if (text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') {
1448
+ const start = i;
1449
+ i += 3; // Skip opening ```
1450
+
1451
+ // Find closing ```
1452
+ while (i < len - 2) {
1453
+ if (text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') {
1454
+ i += 3; // Include closing ```
1455
+ positions.push({ start, end: i });
1456
+ break;
1457
+ }
1458
+ i++;
1459
+ }
1460
+ continue;
1461
+ }
1462
+
1463
+ // Check for inline code (`)
1464
+ if (text[i] === '`') {
1465
+ const start = i;
1466
+ i++; // Skip opening `
1467
+
1468
+ // Find closing `
1469
+ while (i < len) {
1470
+ if (text[i] === '`') {
1471
+ i++; // Include closing `
1472
+ positions.push({ start, end: i });
1473
+ break;
1474
+ }
1475
+ i++;
1476
+ }
1477
+ continue;
1478
+ }
1479
+
1480
+ i++;
1481
+ }
1482
+
1483
+ return positions;
1484
+ };
1485
+
1486
+ _getMarkdownCodePositionsRegex (text) {
1487
+ // OLD IMPLEMENTATION - DO NOT USE IN PRODUCTION
1488
+ // This regex can cause catastrophic backtracking with nested backticks
1368
1489
  const positions = [];
1369
1490
  const codeBlockRe = /```[\s\S]*?```/g;
1370
1491
  const inlineCodeRe = /`[^`]*`/g;
@@ -20,6 +20,7 @@ import _ignore from 'ignore'
20
20
  import { sort } from 'fast-sort'
21
21
  import { buildMigrateConfig } from '../migrate-config.js'
22
22
  import { loadYAML, dumpYAML } from '../adapters/yaml.js';
23
+ import { logger } from '../adapters/logger.js'
23
24
  import realFs from 'fs'
24
25
  import { preparePathForWriting, readFile, writeFile, stat, lstat, unlink } from '../adapters/file-gateway.js'
25
26
 
@@ -320,9 +321,16 @@ export function fileSystemStoreMixin(repo, fs = realFs) {
320
321
  if (!File.isFile(file)) throw new Error(ERRORS.NOT_A_FILE)
321
322
  var filePath = repo.getFullPath(file)
322
323
 
324
+ const readStartTime = Date.now()
325
+ logger.debug(`[INSTRUMENT] readFileContent START for ${filePath}`)
326
+ logger.debug(`[INSTRUMENT] About to stat ${filePath}`)
323
327
  const stats = await stat(filePath)
328
+ logger.debug(`[INSTRUMENT] stat took ${Date.now() - readStartTime}ms for ${filePath}`)
324
329
  if (!stats.isFile()) return file
330
+ const beforeReadTime = Date.now()
331
+ logger.debug(`[INSTRUMENT] About to readFile ${filePath}`)
325
332
  const data = await readFile(filePath, 'utf8')
333
+ logger.debug(`[INSTRUMENT] readFile took ${Date.now() - beforeReadTime}ms for ${filePath} (${data?.length || 0} chars)`)
326
334
 
327
335
  file
328
336
  .setContentFromFile(data)
@@ -330,6 +338,7 @@ export function fileSystemStoreMixin(repo, fs = realFs) {
330
338
  .setCreatedTime(stats.birthtime)
331
339
 
332
340
  file.lineCount = eol.split(data).length
341
+ logger.debug(`[INSTRUMENT] readFileContent END for ${filePath} total ${Date.now() - readStartTime}ms`)
333
342
  return file
334
343
  }
335
344
 
package/lib/project.js CHANGED
@@ -558,23 +558,33 @@ export class WorkerProject extends Project {
558
558
  }
559
559
 
560
560
  async addTaskToFile({path, list = this.config?.getDefaultList(), content, tags = [], contexts = [], meta = [], useCardTemplate = false}) {
561
+ logger.debug(`[INSTRUMENT] addTaskToFile START - path: ${path}, contentLength: ${content?.length || 0}`)
562
+
561
563
  const pluginMods = await this.pluginManager.onBeforeAddTask({path, list, meta, tags, contexts, content, useCardTemplate})
562
564
  path = pluginMods.path
563
565
  content = pluginMods.content
564
566
  meta = pluginMods.meta
565
567
  tags = pluginMods.tags
566
568
  contexts = pluginMods.contexts
569
+ logger.debug(`[INSTRUMENT] addTaskToFile - after onBeforeAddTask, contentLength: ${content?.length || 0}`)
570
+
567
571
  const cardData = await this.newCard({list, path, title: eol.split(content)[0], emit: false})
568
572
  const filePath = cardData.path
569
573
  if (useCardTemplate) content += cardData.template
574
+ logger.debug(`[INSTRUMENT] addTaskToFile - after newCard, contentLength: ${content?.length || 0}`)
570
575
 
571
576
  const boardData = await this.pluginManager.getBoardProperties();
572
577
  const data = { ...boardData, ...cardData}
578
+ logger.debug(`[INSTRUMENT] addTaskToFile - about to call format(), contentLength: ${content?.length || 0}`)
579
+ const formatStart = Date.now()
573
580
  content = format(content, data)
581
+ const formatTime = Date.now() - formatStart
582
+ logger.debug(`[INSTRUMENT] addTaskToFile - format() completed in ${formatTime}ms, contentLength: ${content?.length || 0}`)
574
583
 
575
584
  content = this.addTagsToContent(tags, content)
576
585
  content = this.addContextsToContent(contexts, content)
577
586
  content = this.addMetaToContent(meta, content)
587
+ logger.debug(`[INSTRUMENT] addTaskToFile - about to call repo.addTaskToFile, contentLength: ${content?.length || 0}`)
578
588
 
579
589
  return await this.repo.addTaskToFile(filePath, list, content)
580
590
  }
package/lib/repository.js CHANGED
@@ -755,20 +755,54 @@ export class Repository extends Emitter {
755
755
  // order:-100
756
756
  // -->
757
757
  async extractTasks (file) {
758
+ // INSTRUMENTATION: Track timing for hang investigation
759
+ const extractStartTime = Date.now()
760
+ logger.debug(`[INSTRUMENT] extractTasks START for ${file.path}`)
761
+
758
762
  if (file.content === null) {
759
763
  await this.readFileContent(file)
760
764
  }
765
+
766
+ const readTime = Date.now()
767
+ logger.debug(`[INSTRUMENT] readFileContent took ${readTime - extractStartTime}ms`)
768
+
761
769
  file.on('task.found', this.taskFoundListener)
762
770
  file.on('task.modified', this.taskModifiedListener)
763
771
  const fileContent = file.content
772
+
773
+ logger.debug(`[INSTRUMENT] About to call extractAndTransformTasks, content length: ${fileContent?.length || 0}`)
764
774
  file.extractAndTransformTasks(this.getConfig())
775
+
776
+ const transformTime = Date.now()
777
+ logger.debug(`[INSTRUMENT] extractAndTransformTasks took ${transformTime - readTime}ms`)
778
+
765
779
  if (!file.isModified() || fileContent === file.content) {
766
780
  this.resetFile(file)
781
+ const totalTime = Date.now() - extractStartTime
782
+ logger.debug(`[INSTRUMENT] extractTasks END (no changes) - total ${totalTime}ms`)
767
783
  return file
768
784
  }
785
+
786
+ logger.debug(`[INSTRUMENT] About to call file.extractTasks`)
769
787
  file.extractTasks(this.getConfig())
770
- if (file.modified) await this.writeFile(file)
788
+
789
+ const extractTime = Date.now()
790
+ logger.debug(`[INSTRUMENT] file.extractTasks took ${extractTime - transformTime}ms`)
791
+
792
+ if (file.modified) {
793
+ logger.debug(`[INSTRUMENT] About to writeFile`)
794
+ await this.writeFile(file)
795
+ logger.debug(`[INSTRUMENT] writeFile took ${Date.now() - extractTime}ms`)
796
+ }
797
+
771
798
  this.resetFile(file)
799
+
800
+ const totalTime = Date.now() - extractStartTime
801
+ if (totalTime > 1000) {
802
+ logger.warn(`[INSTRUMENT] ⚠️ SLOW extractTasks: ${file.path} took ${totalTime}ms`)
803
+ }
804
+ logger.debug(`[INSTRUMENT] extractTasks END - total ${totalTime}ms`)
805
+
772
806
  return file
773
807
  }
774
808
 
@@ -1081,24 +1115,35 @@ export class Repository extends Emitter {
1081
1115
  // order:-80
1082
1116
  // -->
1083
1117
  async writeAndExtract (file, emit) {
1118
+ logger.debug(`[INSTRUMENT] writeAndExtract START - file: ${file.path}`)
1119
+
1084
1120
  try {
1121
+ logger.debug(`[INSTRUMENT] writeAndExtract - about to call writeFile`)
1085
1122
  await this.writeFile(file, emit)
1123
+ logger.debug(`[INSTRUMENT] writeAndExtract - writeFile completed`)
1086
1124
  } catch (err) {
1087
1125
  new Error('Unable to write file:' + file.path, {cause: err})
1088
1126
  }
1089
1127
 
1090
1128
  try {
1129
+ logger.debug(`[INSTRUMENT] writeAndExtract - about to call extractTasks`)
1130
+ const extractStart = Date.now()
1091
1131
  await this.extractTasks(file)
1132
+ const extractTime = Date.now() - extractStart
1133
+ logger.debug(`[INSTRUMENT] writeAndExtract - extractTasks completed in ${extractTime}ms`)
1092
1134
  } catch (err) {
1093
1135
  new Error('Unable to extract tasks for file:' + file.path, { cause: err })
1094
1136
  }
1095
1137
 
1096
1138
  try {
1139
+ logger.debug(`[INSTRUMENT] writeAndExtract - about to call addFile`)
1097
1140
  await this.addFile(file)
1141
+ logger.debug(`[INSTRUMENT] writeAndExtract - addFile completed`)
1098
1142
  } catch (err) {
1099
1143
  new Error('Unable to add file after extracting tasks: ' + file.path, { cause: err })
1100
1144
  }
1101
1145
 
1146
+ logger.debug(`[INSTRUMENT] writeAndExtract END`)
1102
1147
  return file
1103
1148
  }
1104
1149
 
@@ -1223,6 +1268,8 @@ export class Repository extends Emitter {
1223
1268
  // order:-300
1224
1269
  // -->
1225
1270
  async appendTask({file, content, list}) {
1271
+ logger.debug(`[INSTRUMENT] appendTask START - file: ${file.path}, contentLength: ${content?.length || 0}`)
1272
+
1226
1273
  const config = this.getConfig()
1227
1274
  const interpretedTaskPrefix = interpolate(
1228
1275
  config.getTaskPrefix(),
@@ -1233,6 +1280,8 @@ export class Repository extends Emitter {
1233
1280
  const taskSyntax = config.getNewCardSyntax()
1234
1281
  const taskPrefix = interpretedTaskPrefix ? `${interpretedTaskPrefix} ` : ''
1235
1282
  let fileContent = file.getContent()
1283
+ logger.debug(`[INSTRUMENT] appendTask - existing file contentLength: ${fileContent?.length || 0}`)
1284
+
1236
1285
  const journalTemplate = this.getConfig().journalTemplate
1237
1286
  fileContent = fileContent.trim()
1238
1287
  ? fileContent
@@ -1249,7 +1298,7 @@ export class Repository extends Emitter {
1249
1298
  const order = appContext().projectContext.getOrder(list)
1250
1299
  const description = lines.length > 1 ? Task.padDescription(lines.slice(1), taskPrefix) : []
1251
1300
  const { orderMeta, tokenPrefix } = config
1252
- const rawTask = getRawTask({tokenPrefix, orderMeta, list, order, text, type: taskSyntax})
1301
+ const rawTask = getRawTask({tokenPrefix, orderMeta, list, order, text, type: taskSyntax})
1253
1302
  const taskContent = config.orderMeta
1254
1303
  ? this.getTaskContent({
1255
1304
  description,
@@ -1263,13 +1312,20 @@ export class Repository extends Emitter {
1263
1312
  let appendContent = File.trimBlankLines(
1264
1313
  `${taskPrefix}${rawTask}${eol.lf}${taskContent}`
1265
1314
  )
1266
-
1315
+
1267
1316
  fileContent = `${fileContent}${sep}`
1268
1317
  const cardTerminator = "\n".repeat(2)
1269
- file.setContent(`${fileContent}${appendContent}${cardTerminator}`)
1318
+ const finalContent = `${fileContent}${appendContent}${cardTerminator}`;
1319
+ logger.debug(`[INSTRUMENT] appendTask - final contentLength: ${finalContent?.length || 0}`)
1320
+ logger.debug(`[INSTRUMENT] appendTask - about to call file.setContent`)
1321
+ file.setContent(finalContent)
1270
1322
 
1323
+ logger.debug(`[INSTRUMENT] appendTask - about to call writeAndExtract`)
1271
1324
  await this.writeAndExtract(file)
1325
+ logger.debug(`[INSTRUMENT] appendTask - writeAndExtract completed`)
1326
+
1272
1327
  const task = file.getTasks().find(task => task.text === text && task.list === list)
1328
+ logger.debug(`[INSTRUMENT] appendTask END - task found: ${!!task}`)
1273
1329
  return { file, task }
1274
1330
  }
1275
1331
 
@@ -1279,13 +1335,18 @@ export class Repository extends Emitter {
1279
1335
  // order:-290
1280
1336
  // -->
1281
1337
  async addTaskToFile (filePath, list, content) {
1338
+ logger.debug(`[INSTRUMENT] repo.addTaskToFile START - filePath: ${filePath}, contentLength: ${content?.length || 0}`)
1339
+
1282
1340
  const relPath = this.getRelativePath(filePath)
1283
1341
  let file = this.getFile(relPath)
1284
1342
 
1285
1343
  if (file) {
1344
+ logger.debug(`[INSTRUMENT] repo.addTaskToFile - file exists, reading content`)
1286
1345
  await this.readFileContent(file)
1346
+ logger.debug(`[INSTRUMENT] repo.addTaskToFile - about to call appendTask`)
1287
1347
  return await this.appendTask({file, content, list})
1288
1348
  } else {
1349
+ logger.debug(`[INSTRUMENT] repo.addTaskToFile - creating new file`)
1289
1350
  const modifiedTime = new Date()
1290
1351
  const createdTime = new Date()
1291
1352
  file = new File({
@@ -1296,6 +1357,7 @@ export class Repository extends Emitter {
1296
1357
  createdTime,
1297
1358
  project: this.project,
1298
1359
  })
1360
+ logger.debug(`[INSTRUMENT] repo.addTaskToFile - about to call appendTask for new file`)
1299
1361
  return await this.appendTask({file, content, list})
1300
1362
  }
1301
1363
  }
@@ -1511,4 +1573,4 @@ export class Repository extends Emitter {
1511
1573
  const result = Repository.query(this.getTasks(), queryString)
1512
1574
  return Repository.getTasksByList(this, result, true)
1513
1575
  }
1514
- }
1576
+ }
package/lib/task.js CHANGED
@@ -128,7 +128,7 @@ export class Task {
128
128
 
129
129
  getTextAndDescription() {
130
130
  const taskContent = `${this.markdownBeforeText}${this.text}${lineEnd}${this.descriptionString}`
131
- .replace(/<!--\s*\[\s*([\s\S]*?)\s*\]\s*-->/gm, '$1')
131
+ .replace(/<!--\s*\[\s*([\s\S]*?)\s*\]\s*-->/gm, '$1') // Safe: simple capture group, no backtracking issue
132
132
  return this.getText(
133
133
  {
134
134
  stripMeta: true,
@@ -199,13 +199,31 @@ export class Task {
199
199
  // order:-680
200
200
  // -->
201
201
  let prefix = newLine ? lineEnd : ' '
202
- const commentRegex = /<!--([\s\S]*?)-->/gm
203
- let result, lastIndex, index
204
- while ((result = commentRegex.exec(content))) {
205
- lastIndex = commentRegex.lastIndex
206
- index = result.index
202
+
203
+ // Find last comment using state machine (safe from backtracking)
204
+ let lastIndex = -1;
205
+ const len = content.length;
206
+ let i = 0;
207
+
208
+ while (i < len - 3) {
209
+ // Look for comment start <!--
210
+ if (content[i] === '<' && content[i + 1] === '!' && content[i + 2] === '-' && content[i + 3] === '-') {
211
+ i += 4;
212
+
213
+ // Find comment end -->
214
+ while (i < len - 2) {
215
+ if (content[i] === '-' && content[i + 1] === '-' && content[i + 2] === '>') {
216
+ i += 3;
217
+ lastIndex = i; // Remember end of this comment
218
+ break;
219
+ }
220
+ i++;
221
+ }
222
+ continue;
223
+ }
224
+ i++;
207
225
  }
208
-
226
+
209
227
  if (lastIndex > 0) {
210
228
  let before = content.slice(0, lastIndex - 3).trimEnd()
211
229
  const after = content.slice(lastIndex - 3)
@@ -434,7 +452,42 @@ export class Task {
434
452
  return linkLabelPositions.some(pos => index >= pos.start && index < pos.end);
435
453
  };
436
454
 
437
- static getCommentPositions (text) {
455
+ static getCommentPositions (text, useRegex = false) {
456
+ if (useRegex) {
457
+ return Task._getCommentPositionsRegex(text);
458
+ }
459
+
460
+ // State machine implementation (safe from catastrophic backtracking)
461
+ const positions = [];
462
+ const len = text.length;
463
+ let i = 0;
464
+
465
+ while (i < len - 3) { // Need at least 4 chars for <!--
466
+ // Look for comment start <!--
467
+ if (text[i] === '<' && text[i + 1] === '!' && text[i + 2] === '-' && text[i + 3] === '-') {
468
+ const start = i;
469
+ i += 4; // Skip <!--
470
+
471
+ // Find comment end -->
472
+ while (i < len - 2) {
473
+ if (text[i] === '-' && text[i + 1] === '-' && text[i + 2] === '>') {
474
+ i += 3; // Include -->
475
+ positions.push({ start, end: i });
476
+ break;
477
+ }
478
+ i++;
479
+ }
480
+ continue;
481
+ }
482
+ i++;
483
+ }
484
+
485
+ return positions;
486
+ }
487
+
488
+ static _getCommentPositionsRegex (text) {
489
+ // OLD IMPLEMENTATION - DO NOT USE IN PRODUCTION
490
+ // This regex can cause catastrophic backtracking with nested/malformed comments
438
491
  const positions = [];
439
492
  const re = /<!--[\s\S]*?-->/g;
440
493
  let match;
@@ -517,7 +570,58 @@ export class Task {
517
570
  return task instanceof Task
518
571
  }
519
572
 
520
- static getMarkdownCodePositions (text) {
573
+ static getMarkdownCodePositions (text, useRegex = false) {
574
+ if (useRegex) {
575
+ return Task._getMarkdownCodePositionsRegex(text);
576
+ }
577
+
578
+ // State machine implementation (safe from catastrophic backtracking)
579
+ const positions = [];
580
+ const len = text.length;
581
+ let i = 0;
582
+
583
+ while (i < len) {
584
+ // Check for code block (```)
585
+ if (text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') {
586
+ const start = i;
587
+ i += 3;
588
+
589
+ while (i < len - 2) {
590
+ if (text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') {
591
+ i += 3;
592
+ positions.push({ start, end: i });
593
+ break;
594
+ }
595
+ i++;
596
+ }
597
+ continue;
598
+ }
599
+
600
+ // Check for inline code (`)
601
+ if (text[i] === '`') {
602
+ const start = i;
603
+ i++;
604
+
605
+ while (i < len) {
606
+ if (text[i] === '`') {
607
+ i++;
608
+ positions.push({ start, end: i });
609
+ break;
610
+ }
611
+ i++;
612
+ }
613
+ continue;
614
+ }
615
+
616
+ i++;
617
+ }
618
+
619
+ return positions;
620
+ };
621
+
622
+ static _getMarkdownCodePositionsRegex (text) {
623
+ // OLD IMPLEMENTATION - DO NOT USE IN PRODUCTION
624
+ // This regex can cause catastrophic backtracking
521
625
  const re = /(```[\s\S]*?```|`[^`]+`)/g;
522
626
  let result;
523
627
  let positions = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imdone-core",
3
- "version": "2.1.5",
3
+ "version": "2.1.10",
4
4
  "description": "imdone-core",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Interpolates the content with the provided data.
2
+ * Interpolates the content with the provided data using state machine parsing.
3
3
  * @param {string} content - The content to interpolate.
4
4
  * @param {object} data - The data to use for interpolation.
5
5
  * @param {object} opts - The options for interpolation.
package/types/file.d.ts CHANGED
@@ -363,7 +363,11 @@ export class File extends Emitter<[never]> {
363
363
  createdTime: any;
364
364
  };
365
365
  isWithinCodeSpanOrBlock(pos: any, content: any): boolean;
366
- getMarkdownCodePositions(text: any): {
366
+ getMarkdownCodePositions(text: any, useRegex?: boolean): {
367
+ start: number;
368
+ end: number;
369
+ }[];
370
+ _getMarkdownCodePositionsRegex(text: any): {
367
371
  start: number;
368
372
  end: number;
369
373
  }[];
package/types/task.d.ts CHANGED
@@ -31,7 +31,11 @@ export class Task {
31
31
  end: number;
32
32
  }[];
33
33
  static isResultInMarkdownLinkLabel(linkLabelPositions: any, index: any): any;
34
- static getCommentPositions(text: any): {
34
+ static getCommentPositions(text: any, useRegex?: boolean): {
35
+ start: number;
36
+ end: number;
37
+ }[];
38
+ static _getCommentPositionsRegex(text: any): {
35
39
  start: number;
36
40
  end: number;
37
41
  }[];
@@ -49,7 +53,11 @@ export class Task {
49
53
  * @return BinaryExpression
50
54
  */
51
55
  static isTask(task: any): task is Task;
52
- static getMarkdownCodePositions(text: any): {
56
+ static getMarkdownCodePositions(text: any, useRegex?: boolean): {
57
+ start: number;
58
+ end: number;
59
+ }[];
60
+ static _getMarkdownCodePositionsRegex(text: any): {
53
61
  start: number;
54
62
  end: number;
55
63
  }[];