pi-lens 3.7.1 → 3.8.2

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +706 -619
  3. package/clients/architect-client.ts +7 -2
  4. package/clients/ast-grep-client.ts +7 -1
  5. package/clients/dispatch/plan.ts +10 -4
  6. package/clients/dispatch/runners/architect.ts +20 -7
  7. package/clients/dispatch/runners/ast-grep-napi.ts +5 -2
  8. package/clients/dispatch/runners/ast-grep.ts +29 -18
  9. package/clients/dispatch/runners/biome.ts +4 -4
  10. package/clients/dispatch/runners/python-slop.ts +17 -7
  11. package/clients/dispatch/runners/ruff.ts +4 -4
  12. package/clients/dispatch/runners/tree-sitter.ts +30 -19
  13. package/clients/dispatch/runners/ts-slop.ts +17 -7
  14. package/clients/dispatch/runners/utils/runner-helpers.ts +76 -8
  15. package/clients/dispatch/utils/format-utils.ts +2 -1
  16. package/clients/fix-scanners.ts +8 -8
  17. package/clients/installer/index.ts +19 -1
  18. package/clients/lsp/index.ts +0 -40
  19. package/clients/lsp/launch.ts +5 -2
  20. package/clients/package-root.ts +44 -0
  21. package/clients/pipeline.ts +179 -8
  22. package/clients/scan-utils.ts +20 -32
  23. package/clients/sg-runner.ts +7 -5
  24. package/clients/source-filter.ts +222 -0
  25. package/clients/startup-scan.ts +142 -0
  26. package/clients/todo-scanner.ts +44 -55
  27. package/clients/tree-sitter-cache.ts +315 -0
  28. package/clients/tree-sitter-client.ts +208 -52
  29. package/clients/tree-sitter-fixer.ts +217 -0
  30. package/clients/tree-sitter-navigator.ts +329 -0
  31. package/clients/tree-sitter-query-loader.ts +55 -32
  32. package/commands/booboo.ts +47 -35
  33. package/default-architect.yaml +76 -87
  34. package/docs/ARCHITECTURE.md +74 -0
  35. package/docs/AST_GREP_RULES.md +266 -0
  36. package/docs/COMPLEXITY_METRICS.md +120 -0
  37. package/docs/EXCLUSIONS.md +83 -0
  38. package/docs/LSP_CONFIG.md +240 -0
  39. package/docs/TREE_SITTER_RULES.md +340 -0
  40. package/docs/WRITING_NEW_AST_GREP_RULES.md +200 -0
  41. package/index.ts +209 -86
  42. package/package.json +13 -4
  43. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +33 -0
  44. package/rules/ast-grep-rules/rules/array-callback-return.yml +1 -1
  45. package/rules/ast-grep-rules/rules/constructor-super-js.yml +22 -0
  46. package/rules/ast-grep-rules/rules/empty-catch-js.yml +45 -0
  47. package/rules/ast-grep-rules/rules/empty-catch.yml +1 -1
  48. package/rules/ast-grep-rules/rules/getter-return-js.yml +59 -0
  49. package/rules/ast-grep-rules/rules/getter-return.yml +1 -1
  50. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +12 -0
  51. package/rules/ast-grep-rules/rules/jsx-boolean-short-circuit.yml +1 -1
  52. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +14 -0
  53. package/rules/ast-grep-rules/rules/missed-concurrency-js.yml +25 -0
  54. package/rules/ast-grep-rules/rules/nested-ternary-js.yml +10 -0
  55. package/rules/ast-grep-rules/rules/no-alert-js.yml +6 -0
  56. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +21 -18
  57. package/rules/ast-grep-rules/rules/no-array-constructor-js.yml +10 -0
  58. package/rules/ast-grep-rules/rules/no-async-promise-executor-js.yml +15 -0
  59. package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +1 -1
  60. package/rules/ast-grep-rules/rules/no-await-in-loop-js.yml +30 -0
  61. package/rules/ast-grep-rules/rules/no-await-in-promise-all-js.yml +20 -0
  62. package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +1 -1
  63. package/rules/ast-grep-rules/rules/no-bare-except.yml +1 -1
  64. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +16 -0
  65. package/rules/ast-grep-rules/rules/no-compare-neg-zero-js.yml +13 -0
  66. package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +1 -1
  67. package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-cond-assign-js.yml +36 -0
  69. package/rules/ast-grep-rules/rules/no-cond-assign.yml +1 -1
  70. package/rules/ast-grep-rules/rules/no-constant-condition-js.yml +25 -0
  71. package/rules/ast-grep-rules/rules/no-constant-condition.yml +1 -1
  72. package/rules/ast-grep-rules/rules/no-constructor-return-js.yml +28 -0
  73. package/rules/ast-grep-rules/rules/no-constructor-return.yml +1 -1
  74. package/rules/ast-grep-rules/rules/no-discarded-error-js.yml +25 -0
  75. package/rules/ast-grep-rules/rules/no-discarded-error.yml +25 -0
  76. package/rules/ast-grep-rules/rules/no-dupe-args-js.yml +15 -0
  77. package/rules/ast-grep-rules/rules/no-dupe-keys-js.yml +73 -0
  78. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +25 -0
  79. package/rules/ast-grep-rules/rules/no-hardcoded-secrets-js.yml +17 -0
  80. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +15 -0
  81. package/rules/ast-grep-rules/rules/no-inner-html-js.yml +13 -0
  82. package/rules/ast-grep-rules/rules/no-insecure-randomness-js.yml +20 -0
  83. package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +1 -1
  84. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +11 -0
  85. package/rules/ast-grep-rules/rules/no-nan-comparison-js.yml +22 -0
  86. package/rules/ast-grep-rules/rules/no-nan-comparison.yml +22 -0
  87. package/rules/ast-grep-rules/rules/no-new-symbol-js.yml +8 -0
  88. package/rules/ast-grep-rules/rules/no-new-wrappers-js.yml +13 -0
  89. package/rules/ast-grep-rules/rules/no-open-redirect-js.yml +15 -0
  90. package/rules/ast-grep-rules/rules/no-prototype-builtins-js.yml +15 -0
  91. package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +1 -1
  92. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +13 -0
  93. package/rules/ast-grep-rules/rules/no-sql-in-code.yml +1 -1
  94. package/rules/ast-grep-rules/rules/no-throw-string-js.yml +12 -0
  95. package/rules/ast-grep-rules/rules/no-throw-string.yml +1 -1
  96. package/rules/ast-grep-rules/rules/strict-equality-js.yml +10 -0
  97. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +10 -0
  98. package/rules/ast-grep-rules/rules/toctou-js.yml +112 -0
  99. package/rules/ast-grep-rules/rules/toctou.yml +1 -1
  100. package/rules/ast-grep-rules/rules/unchecked-sync-fs-js.yml +44 -0
  101. package/rules/ast-grep-rules/rules/unchecked-sync-fs.yml +44 -0
  102. package/rules/ast-grep-rules/rules/unchecked-throwing-call-js.yml +31 -0
  103. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +48 -0
  104. package/rules/ast-grep-rules/rules/unchecked-throwing-call-ruby.yml +47 -0
  105. package/rules/ast-grep-rules/rules/unchecked-throwing-call.yml +31 -0
  106. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +15 -0
  107. package/rules/tree-sitter-queries/go/go-bare-error.yml +47 -0
  108. package/rules/tree-sitter-queries/go/go-defer-in-loop.yml +47 -0
  109. package/rules/tree-sitter-queries/go/go-hardcoded-secrets.yml +54 -0
  110. package/rules/tree-sitter-queries/python/is-vs-equals.yml +1 -1
  111. package/rules/tree-sitter-queries/python/python-debugger.yml +46 -0
  112. package/rules/tree-sitter-queries/python/python-empty-except.yml +48 -0
  113. package/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml +44 -0
  114. package/rules/tree-sitter-queries/python/python-mutable-class-attr.yml +57 -0
  115. package/rules/tree-sitter-queries/python/python-print-statement.yml +53 -0
  116. package/rules/tree-sitter-queries/python/python-raise-string.yml +38 -0
  117. package/rules/tree-sitter-queries/python/python-unsafe-regex.yml +58 -0
  118. package/rules/tree-sitter-queries/ruby/ruby-debugger.yml +44 -0
  119. package/rules/tree-sitter-queries/ruby/ruby-empty-rescue.yml +47 -0
  120. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +43 -0
  121. package/rules/tree-sitter-queries/ruby/ruby-hardcoded-secrets.yml +40 -0
  122. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +48 -0
  123. package/rules/tree-sitter-queries/ruby/ruby-puts-statement.yml +52 -0
  124. package/rules/tree-sitter-queries/ruby/ruby-rescue-exception.yml +51 -0
  125. package/rules/tree-sitter-queries/ruby/ruby-unsafe-regex.yml +49 -0
  126. package/rules/tree-sitter-queries/rust/rust-clone-in-loop.yml +49 -0
  127. package/rules/tree-sitter-queries/rust/rust-unwrap.yml +45 -0
  128. package/rules/tree-sitter-queries/typescript/console-statement.yml +3 -3
  129. package/rules/tree-sitter-queries/typescript/hardcoded-secrets.yml +13 -27
  130. package/rules/tree-sitter-queries/typescript/injections.scm +40 -0
  131. package/rules/tree-sitter-queries/typescript/no-console-in-tests.yml +52 -0
  132. package/rules/tree-sitter-queries/typescript/sql-injection.yml +55 -0
  133. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +71 -0
  134. package/rules/tree-sitter-queries/typescript/variable-shadowing.yml +51 -0
  135. package/scripts/download-grammars.ts +78 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Source File Filter — Deduplicates source files by detecting build artifacts.
3
+ *
4
+ * Problem: When scanning a codebase, we encounter both source files and their
5
+ * compiled/transpiled outputs (TypeScript → JavaScript, Vue → JavaScript, etc.).
6
+ * Scanning both wastes time and produces duplicate findings.
7
+ *
8
+ * Solution: For each file, check if a "higher precedence" source sibling exists.
9
+ * If yes, skip the file as a build artifact. If no, keep it as hand-written source.
10
+ *
11
+ * Supported ecosystems:
12
+ * - TypeScript: .ts shadows .js, .tsx shadows .jsx
13
+ * - Vue/Svelte: .vue/.svelte shadows .js
14
+ * - CoffeeScript: .coffee shadows .js
15
+ *
16
+ * Files without higher-precedence siblings are always kept (hand-written JS, Python,
17
+ * Go, Rust, etc.).
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ /**
24
+ * Mapping of file extension to the extensions it shadows (build artifacts).
25
+ * Order matters: first entry has highest precedence.
26
+ */
27
+ export const SOURCE_PRECEDENCE: Record<string, string[]> = {
28
+ ".ts": [".js", ".mjs", ".cjs"],
29
+ ".tsx": [".jsx", ".js", ".mjs", ".cjs"],
30
+ ".vue": [".js", ".mjs"],
31
+ ".svelte": [".js", ".mjs"],
32
+ ".coffee": [".js"],
33
+ };
34
+
35
+ /**
36
+ * All extensions that could be source or artifacts, in precedence order.
37
+ */
38
+ export const ALL_SCANNABLE_EXTENSIONS = [
39
+ ".ts",
40
+ ".tsx",
41
+ ".js",
42
+ ".jsx",
43
+ ".mjs",
44
+ ".cjs",
45
+ ".vue",
46
+ ".svelte",
47
+ ".coffee",
48
+ ".py",
49
+ ".go",
50
+ ".rs",
51
+ ".rb",
52
+ ".rake",
53
+ ".gemspec",
54
+ ".ru",
55
+ ];
56
+
57
+ /**
58
+ * Extract the basename (filename without extension) from a path.
59
+ */
60
+ function getBasename(filePath: string): string {
61
+ const ext = path.extname(filePath);
62
+ return path.basename(filePath, ext);
63
+ }
64
+
65
+ /**
66
+ * Get the directory of a file path.
67
+ */
68
+ function getDir(filePath: string): string {
69
+ return path.dirname(filePath);
70
+ }
71
+
72
+ /**
73
+ * Check if a file has a higher-precedence source sibling.
74
+ * Returns the shadowing source file path if found, null otherwise.
75
+ */
76
+ export function findSourceSibling(filePath: string): string | null {
77
+ const ext = path.extname(filePath).toLowerCase();
78
+ const dir = getDir(filePath);
79
+ const base = getBasename(filePath);
80
+
81
+ // Find which precedence group this extension belongs to
82
+ for (const [sourceExt, shadowedExts] of Object.entries(SOURCE_PRECEDENCE)) {
83
+ if (shadowedExts.includes(ext)) {
84
+ // This file could be shadowed by a source file with sourceExt
85
+ const siblingPath = path.join(dir, base + sourceExt);
86
+ if (fs.existsSync(siblingPath)) {
87
+ return siblingPath;
88
+ }
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Check if a file is a build artifact (has a source sibling).
97
+ */
98
+ export function isBuildArtifact(filePath: string): boolean {
99
+ return findSourceSibling(filePath) !== null;
100
+ }
101
+
102
+ /**
103
+ * Filter a list of files, removing build artifacts that have source siblings.
104
+ * Returns de-duplicated list keeping only highest-precedence sources.
105
+ */
106
+ export function filterSourceFiles(filePaths: string[]): string[] {
107
+ // Track which files we're keeping and why we're skipping others
108
+ const keep: string[] = [];
109
+ const skipReasons = new Map<string, string>(); // skipped file -> kept source
110
+
111
+ for (const filePath of filePaths) {
112
+ const sourceSibling = findSourceSibling(filePath);
113
+ if (sourceSibling) {
114
+ // This is a build artifact, skip it
115
+ skipReasons.set(filePath, sourceSibling);
116
+ } else {
117
+ // No higher-precedence source, keep it
118
+ keep.push(filePath);
119
+ }
120
+ }
121
+
122
+ return keep;
123
+ }
124
+
125
+ /**
126
+ * Recursively collect all source files in a directory, excluding build artifacts.
127
+ *
128
+ * @param dir - Directory to scan
129
+ * @param options - Optional configuration
130
+ * @returns Array of absolute file paths that are source files (not build artifacts)
131
+ */
132
+ export function collectSourceFiles(
133
+ dir: string,
134
+ options?: {
135
+ /** Additional directory names to exclude (merged with defaults) */
136
+ excludeDirs?: string[];
137
+ /** File extensions to consider (defaults to ALL_SCANNABLE_EXTENSIONS) */
138
+ extensions?: string[];
139
+ /** Whether to follow symlinks (default: false) */
140
+ followSymlinks?: boolean;
141
+ },
142
+ ): string[] {
143
+ const excludeDirs = new Set([
144
+ "node_modules",
145
+ ".git",
146
+ "dist",
147
+ "build",
148
+ ".next",
149
+ "coverage",
150
+ "__pycache__",
151
+ ".cache",
152
+ "target", // Rust
153
+ "out",
154
+ "*.dSYM", // macOS debug symbols
155
+ ...(options?.excludeDirs || []),
156
+ ]);
157
+
158
+ const extensions = new Set(options?.extensions || ALL_SCANNABLE_EXTENSIONS);
159
+
160
+ const files: string[] = [];
161
+
162
+ function scan(currentDir: string) {
163
+ let entries: fs.Dirent[] = [];
164
+ try {
165
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
166
+ } catch {
167
+ return; // Permission denied or directory doesn't exist
168
+ }
169
+
170
+ for (const entry of entries) {
171
+ const fullPath = path.join(currentDir, entry.name);
172
+
173
+ if (entry.isDirectory()) {
174
+ if (excludeDirs.has(entry.name)) continue;
175
+ if (!options?.followSymlinks && entry.isSymbolicLink()) continue;
176
+ scan(fullPath);
177
+ } else if (entry.isFile()) {
178
+ const ext = path.extname(entry.name).toLowerCase();
179
+ if (!extensions.has(ext)) continue;
180
+
181
+ // Skip if this is a build artifact
182
+ if (isBuildArtifact(fullPath)) continue;
183
+
184
+ files.push(fullPath);
185
+ }
186
+ }
187
+ }
188
+
189
+ scan(path.resolve(dir));
190
+ return files;
191
+ }
192
+
193
+ /**
194
+ * Get statistics about source file filtering for debugging/monitoring.
195
+ */
196
+ export function getFilterStats(
197
+ allFiles: string[],
198
+ filteredFiles: string[],
199
+ ): {
200
+ total: number;
201
+ kept: number;
202
+ skipped: number;
203
+ byType: Record<string, number>;
204
+ } {
205
+ const skipped = allFiles.length - filteredFiles.length;
206
+ const byType: Record<string, number> = {};
207
+
208
+ // Count what we skipped
209
+ for (const file of allFiles) {
210
+ if (!filteredFiles.includes(file)) {
211
+ const ext = path.extname(file).toLowerCase();
212
+ byType[ext] = (byType[ext] || 0) + 1;
213
+ }
214
+ }
215
+
216
+ return {
217
+ total: allFiles.length,
218
+ kept: filteredFiles.length,
219
+ skipped,
220
+ byType,
221
+ };
222
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Startup scan safety — gates eager cache warmups to real project roots.
3
+ *
4
+ * Prevents pi-lens from scanning $HOME or generic directories at session
5
+ * start, which would hang or produce meaningless results.
6
+ *
7
+ * Credit: alexx-ftw (PR #1)
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { EXCLUDED_DIRS } from "./file-utils.js";
14
+
15
+ export const PROJECT_ROOT_MARKERS = [
16
+ ".git",
17
+ "package.json",
18
+ "pyproject.toml",
19
+ "Cargo.toml",
20
+ "go.mod",
21
+ "composer.json",
22
+ ];
23
+
24
+ export const MAX_STARTUP_SOURCE_FILES = 2000;
25
+
26
+ const SOURCE_FILE_PATTERN = /\.(ts|tsx|js|jsx|py|go|rs|rb)$/;
27
+
28
+ export interface StartupScanContext {
29
+ cwd: string;
30
+ scanRoot: string;
31
+ projectRoot: string | null;
32
+ canWarmCaches: boolean;
33
+ reason?: "home-dir" | "no-project-root" | "too-many-source-files";
34
+ sourceFileCount?: number;
35
+ }
36
+
37
+ export interface StartupScanOptions {
38
+ homeDir?: string;
39
+ maxSourceFiles?: number;
40
+ }
41
+
42
+ export function findNearestProjectRoot(startDir: string): string | null {
43
+ let current = path.resolve(startDir);
44
+ while (true) {
45
+ if (
46
+ PROJECT_ROOT_MARKERS.some((marker) =>
47
+ fs.existsSync(path.join(current, marker)),
48
+ )
49
+ ) {
50
+ return current;
51
+ }
52
+ const parent = path.dirname(current);
53
+ if (parent === current) return null;
54
+ current = parent;
55
+ }
56
+ }
57
+
58
+ export function countSourceFilesWithinLimit(
59
+ dir: string,
60
+ limit: number,
61
+ ): number {
62
+ let count = 0;
63
+ const stack = [path.resolve(dir)];
64
+
65
+ while (stack.length > 0) {
66
+ const current = stack.pop();
67
+ if (!current) continue;
68
+
69
+ let entries: fs.Dirent[] = [];
70
+ try {
71
+ entries = fs.readdirSync(current, { withFileTypes: true });
72
+ } catch {
73
+ continue;
74
+ }
75
+
76
+ for (const entry of entries) {
77
+ if (entry.isDirectory()) {
78
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
79
+ stack.push(path.join(current, entry.name));
80
+ continue;
81
+ }
82
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
83
+ count += 1;
84
+ if (count > limit) return count;
85
+ }
86
+ }
87
+ }
88
+ return count;
89
+ }
90
+
91
+ export function resolveStartupScanContext(
92
+ cwd: string,
93
+ options: StartupScanOptions = {},
94
+ ): StartupScanContext {
95
+ const resolvedCwd = path.resolve(cwd);
96
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
97
+ const maxSourceFiles = options.maxSourceFiles ?? MAX_STARTUP_SOURCE_FILES;
98
+ const projectRoot = findNearestProjectRoot(resolvedCwd);
99
+
100
+ if (!projectRoot) {
101
+ return {
102
+ cwd: resolvedCwd,
103
+ scanRoot: resolvedCwd,
104
+ projectRoot: null,
105
+ canWarmCaches: false,
106
+ reason: resolvedCwd === homeDir ? "home-dir" : "no-project-root",
107
+ };
108
+ }
109
+
110
+ if (path.resolve(projectRoot) === homeDir) {
111
+ return {
112
+ cwd: resolvedCwd,
113
+ scanRoot: projectRoot,
114
+ projectRoot,
115
+ canWarmCaches: false,
116
+ reason: "home-dir",
117
+ };
118
+ }
119
+
120
+ const sourceFileCount = countSourceFilesWithinLimit(
121
+ projectRoot,
122
+ maxSourceFiles,
123
+ );
124
+ if (sourceFileCount > maxSourceFiles) {
125
+ return {
126
+ cwd: resolvedCwd,
127
+ scanRoot: projectRoot,
128
+ projectRoot,
129
+ canWarmCaches: false,
130
+ reason: "too-many-source-files",
131
+ sourceFileCount,
132
+ };
133
+ }
134
+
135
+ return {
136
+ cwd: resolvedCwd,
137
+ scanRoot: projectRoot,
138
+ projectRoot,
139
+ canWarmCaches: true,
140
+ sourceFileCount,
141
+ };
142
+ }
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * TODO Scanner for pi-local.
3
3
  *
4
- * Scans codebase for TODO, FIXME, HACK, XXX, and other annotations.
5
- * Helps me understand what's already flagged as problematic or incomplete.
4
+ * Scans codebase for TODO, FIXME, HACK, XXX, and BUG annotations.
5
+ * Helps understand what's already flagged as problematic or incomplete.
6
6
  *
7
7
  * No dependencies required — uses regex scanning.
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { collectSourceFiles } from "./source-filter.js";
12
13
 
13
14
  // --- Types ---
14
15
 
15
16
  export interface TodoItem {
16
- type: "TODO" | "FIXME" | "HACK" | "XXX" | "NOTE" | "DEPRECATED" | "BUG";
17
+ type: "TODO" | "FIXME" | "HACK" | "XXX" | "BUG";
17
18
  message: string;
18
19
  file: string;
19
20
  line: number;
@@ -29,8 +30,12 @@ export interface TodoScanResult {
29
30
  // --- Scanner ---
30
31
 
31
32
  export class TodoScanner {
32
- private readonly pattern =
33
- /\b(TODO|FIXME|HACK|XXX|NOTE|DEPRECATED|BUG)\b\s*[(:]?\s*(.+)/gi;
33
+ /**
34
+ * Pattern matches actionable annotations only.
35
+ * Excludes NOTE and DEPRECATED — these are documentation, not work items.
36
+ * Case-sensitive to avoid matching "Note:" in prose.
37
+ */
38
+ private readonly pattern = /\b(TODO|FIXME|HACK|XXX|BUG)\b\s*[(:]?\s*(.+)/g;
34
39
 
35
40
  /**
36
41
  * Check if a match position is inside a comment context.
@@ -80,7 +85,7 @@ export class TodoScanner {
80
85
  }
81
86
 
82
87
  /**
83
- * Scan a single file for TODOs
88
+ * Scan a single file for TODOs.
84
89
  */
85
90
  scanFile(filePath: string): TodoItem[] {
86
91
  const absolutePath = path.resolve(filePath);
@@ -98,7 +103,7 @@ export class TodoScanner {
98
103
  // Skip matches that aren't inside comments
99
104
  if (!this.isInComment(line, match.index ?? 0)) continue;
100
105
 
101
- const type = match[1].toUpperCase() as TodoItem["type"];
106
+ const type = match[1] as TodoItem["type"];
102
107
  const message = (match[2] || "").trim().replace(/\s*\*\/\s*$/, ""); // Strip closing comment
103
108
 
104
109
  items.push({
@@ -115,52 +120,42 @@ export class TodoScanner {
115
120
  }
116
121
 
117
122
  /**
118
- * Scan a directory recursively
123
+ * Scan a list of pre-filtered files (recommended — uses source-filter module).
124
+ * Callers should use collectSourceFiles() to get deduplicated source files.
119
125
  */
120
- scanDirectory(
121
- dirPath: string,
122
- extensions = [".ts", ".tsx", ".js", ".jsx", ".py"],
123
- ): TodoScanResult {
126
+ scanFiles(filePaths: string[]): TodoScanResult {
124
127
  const items: TodoItem[] = [];
125
128
 
126
- const scan = (dir: string) => {
127
- if (!fs.existsSync(dir)) return;
128
-
129
- const entries = fs.readdirSync(dir, { withFileTypes: true });
130
-
131
- for (const entry of entries) {
132
- const fullPath = path.join(dir, entry.name);
133
-
134
- if (entry.isDirectory()) {
135
- // Skip common non-source directories
136
- if (
137
- [
138
- "node_modules",
139
- ".git",
140
- "dist",
141
- "build",
142
- ".next",
143
- "coverage",
144
- ].includes(entry.name)
145
- )
146
- continue;
147
- scan(fullPath);
148
- } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
149
- // Skip this scanner file — its own type literals and regex cause false positives
150
- if (
151
- entry.name === "todo-scanner.ts" ||
152
- entry.name === "todo-scanner.js"
153
- )
154
- continue;
155
- // Skip test files — intentional annotations are test fixtures, not work items
156
- if (/\.(test|spec)\.[jt]sx?$/.test(entry.name)) continue;
157
- items.push(...this.scanFile(fullPath));
158
- }
159
- }
160
- };
129
+ for (const filePath of filePaths) {
130
+ // Skip this scanner file — its own type literals and regex cause false positives
131
+ if (
132
+ filePath.endsWith("todo-scanner.ts") ||
133
+ filePath.endsWith("todo-scanner.js")
134
+ )
135
+ continue;
136
+ // Skip test files — intentional annotations are test fixtures, not work items
137
+ if (/\.(test|spec)\.[jt]sx?$/.test(filePath)) continue;
138
+
139
+ items.push(...this.scanFile(filePath));
140
+ }
141
+
142
+ return this.groupResults(items);
143
+ }
161
144
 
162
- scan(path.resolve(dirPath));
145
+ /**
146
+ * Scan a directory recursively using the source-filter module to exclude build artifacts.
147
+ * This is the preferred entry point for new callers.
148
+ */
149
+ scanDirectory(dirPath: string): TodoScanResult {
150
+ // Use source-filter to collect only source files (no build artifacts)
151
+ const sourceFiles = collectSourceFiles(dirPath);
152
+ return this.scanFiles(sourceFiles);
153
+ }
163
154
 
155
+ /**
156
+ * Group scan results by type and file.
157
+ */
158
+ private groupResults(items: TodoItem[]): TodoScanResult {
164
159
  // Group by type
165
160
  const byType = new Map<string, TodoItem[]>();
166
161
  for (const item of items) {
@@ -181,7 +176,7 @@ export class TodoScanner {
181
176
  }
182
177
 
183
178
  /**
184
- * Format scan results for LLM consumption
179
+ * Format scan results for LLM consumption.
185
180
  */
186
181
  formatResult(result: TodoScanResult, maxItems = 30): string {
187
182
  if (result.items.length === 0) return "";
@@ -203,10 +198,8 @@ export class TodoScanner {
203
198
  "FIXME",
204
199
  "HACK",
205
200
  "BUG",
206
- "DEPRECATED",
207
201
  "TODO",
208
202
  "XXX",
209
- "NOTE",
210
203
  ];
211
204
  const sorted = [...result.items].sort((a, b) => {
212
205
  const aIdx = priorityOrder.indexOf(a.type);
@@ -234,14 +227,10 @@ export class TodoScanner {
234
227
  return "🟠";
235
228
  case "BUG":
236
229
  return "🐛";
237
- case "DEPRECATED":
238
- return "⚠️";
239
230
  case "TODO":
240
231
  return "📝";
241
232
  case "XXX":
242
233
  return "❌";
243
- case "NOTE":
244
- return "ℹ️";
245
234
  default:
246
235
  return "•";
247
236
  }