nogrep 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +91 -0
  2. package/commands/init.md +241 -0
  3. package/commands/off.md +11 -0
  4. package/commands/on.md +21 -0
  5. package/commands/query.md +13 -0
  6. package/commands/status.md +15 -0
  7. package/commands/update.md +89 -0
  8. package/dist/chunk-SMUAF6SM.js +12 -0
  9. package/dist/chunk-SMUAF6SM.js.map +1 -0
  10. package/dist/query.d.ts +12 -0
  11. package/dist/query.js +272 -0
  12. package/dist/query.js.map +1 -0
  13. package/dist/settings.d.ts +6 -0
  14. package/dist/settings.js +75 -0
  15. package/dist/settings.js.map +1 -0
  16. package/dist/signals.d.ts +9 -0
  17. package/dist/signals.js +174 -0
  18. package/dist/signals.js.map +1 -0
  19. package/dist/trim.d.ts +3 -0
  20. package/dist/trim.js +266 -0
  21. package/dist/trim.js.map +1 -0
  22. package/dist/types.d.ts +141 -0
  23. package/dist/types.js +7 -0
  24. package/dist/types.js.map +1 -0
  25. package/dist/validate.d.ts +10 -0
  26. package/dist/validate.js +143 -0
  27. package/dist/validate.js.map +1 -0
  28. package/dist/write.d.ts +8 -0
  29. package/dist/write.js +267 -0
  30. package/dist/write.js.map +1 -0
  31. package/docs/ARCHITECTURE.md +239 -0
  32. package/docs/CLAUDE.md +161 -0
  33. package/docs/CONVENTIONS.md +162 -0
  34. package/docs/SPEC.md +803 -0
  35. package/docs/TASKS.md +216 -0
  36. package/hooks/hooks.json +35 -0
  37. package/hooks/pre-tool-use.sh +37 -0
  38. package/hooks/prompt-submit.sh +26 -0
  39. package/hooks/session-start.sh +21 -0
  40. package/package.json +24 -0
  41. package/scripts/query.ts +290 -0
  42. package/scripts/settings.ts +98 -0
  43. package/scripts/signals.ts +237 -0
  44. package/scripts/trim.ts +379 -0
  45. package/scripts/types.ts +186 -0
  46. package/scripts/validate.ts +181 -0
  47. package/scripts/write.ts +346 -0
  48. package/templates/claude-md-patch.md +8 -0
@@ -0,0 +1,98 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import type { NogrepSettings } from './types.js'
5
+
6
+ const SETTINGS_FILE = '.claude/settings.json'
7
+ const SETTINGS_LOCAL_FILE = '.claude/settings.local.json'
8
+
9
+ interface SettingsJson {
10
+ nogrep?: Partial<NogrepSettings>
11
+ [key: string]: unknown
12
+ }
13
+
14
+ async function readJsonFile(path: string): Promise<SettingsJson> {
15
+ try {
16
+ const content = await readFile(path, 'utf-8')
17
+ return JSON.parse(content) as SettingsJson
18
+ } catch {
19
+ return {}
20
+ }
21
+ }
22
+
23
+ async function ensureDir(dir: string): Promise<void> {
24
+ await mkdir(dir, { recursive: true })
25
+ }
26
+
27
+ export async function readSettings(projectRoot: string): Promise<NogrepSettings> {
28
+ const sharedPath = join(projectRoot, SETTINGS_FILE)
29
+ const localPath = join(projectRoot, SETTINGS_LOCAL_FILE)
30
+
31
+ const shared = await readJsonFile(sharedPath)
32
+ const local = await readJsonFile(localPath)
33
+
34
+ const enabled =
35
+ local.nogrep?.enabled ?? shared.nogrep?.enabled ?? false
36
+
37
+ return { enabled }
38
+ }
39
+
40
+ export async function writeSettings(
41
+ projectRoot: string,
42
+ settings: Partial<NogrepSettings>,
43
+ local?: boolean,
44
+ ): Promise<void> {
45
+ const filePath = join(
46
+ projectRoot,
47
+ local ? SETTINGS_LOCAL_FILE : SETTINGS_FILE,
48
+ )
49
+
50
+ await ensureDir(join(projectRoot, '.claude'))
51
+
52
+ const existing = await readJsonFile(filePath)
53
+ existing.nogrep = { ...existing.nogrep, ...settings }
54
+
55
+ await writeFile(filePath, JSON.stringify(existing, null, 2) + '\n', 'utf-8')
56
+ }
57
+
58
+ // CLI interface
59
+ async function main(): Promise<void> {
60
+ const { values } = parseArgs({
61
+ options: {
62
+ set: { type: 'string' },
63
+ get: { type: 'boolean', default: false },
64
+ local: { type: 'boolean', default: false },
65
+ root: { type: 'string', default: process.cwd() },
66
+ },
67
+ strict: true,
68
+ })
69
+
70
+ const root = values.root ?? process.cwd()
71
+
72
+ if (values.get) {
73
+ const settings = await readSettings(root)
74
+ process.stdout.write(JSON.stringify(settings, null, 2) + '\n')
75
+ return
76
+ }
77
+
78
+ if (values.set) {
79
+ const [key, value] = values.set.split('=')
80
+ if (key === 'enabled') {
81
+ const enabled = value === 'true'
82
+ await writeSettings(root, { enabled }, values.local)
83
+ } else {
84
+ process.stderr.write(JSON.stringify({ error: `Unknown setting: ${key}` }) + '\n')
85
+ process.exitCode = 1
86
+ }
87
+ return
88
+ }
89
+
90
+ process.stderr.write(JSON.stringify({ error: 'Usage: node settings.js --set enabled=true [--local] | --get' }) + '\n')
91
+ process.exitCode = 1
92
+ }
93
+
94
+ main().catch((err: unknown) => {
95
+ const message = err instanceof Error ? err.message : String(err)
96
+ process.stderr.write(JSON.stringify({ error: message }) + '\n')
97
+ process.exitCode = 1
98
+ })
@@ -0,0 +1,237 @@
1
+ import { readdir, stat, readFile } from 'fs/promises'
2
+ import { join, extname, relative, resolve } from 'path'
3
+ import { execFile } from 'child_process'
4
+ import { promisify } from 'util'
5
+ import type { SignalResult, DirectoryNode, ManifestFile, ChurnEntry, FileSize } from './types.js'
6
+
7
+ const execFileAsync = promisify(execFile)
8
+
9
+ const SKIP_DIRS = new Set([
10
+ 'node_modules', 'dist', 'build', '.git', 'coverage',
11
+ '.next', '.nuxt', '__pycache__', '.venv', 'venv',
12
+ '.idea', '.vscode', '.nogrep',
13
+ ])
14
+
15
+ const MANIFEST_NAMES: Record<string, string> = {
16
+ 'package.json': 'npm',
17
+ 'requirements.txt': 'pip',
18
+ 'pom.xml': 'maven',
19
+ 'go.mod': 'go',
20
+ 'Podfile': 'cocoapods',
21
+ 'Cargo.toml': 'cargo',
22
+ 'pubspec.yaml': 'flutter',
23
+ 'composer.json': 'composer',
24
+ }
25
+
26
+ const ENTRY_NAMES = new Set(['main', 'index', 'app', 'server'])
27
+
28
+ const TEST_PATTERNS = [
29
+ /\.test\.\w+$/,
30
+ /\.spec\.\w+$/,
31
+ /_test\.\w+$/,
32
+ /^test_.*\.py$/,
33
+ ]
34
+
35
+ interface CollectOptions {
36
+ exclude?: string[]
37
+ maxDepth?: number
38
+ }
39
+
40
+ export async function collectSignals(
41
+ root: string,
42
+ options: CollectOptions = {},
43
+ ): Promise<SignalResult> {
44
+ const absRoot = resolve(root)
45
+ const maxDepth = options.maxDepth ?? 4
46
+ const extraSkip = new Set(options.exclude ?? [])
47
+
48
+ const allFiles: { path: string; bytes: number }[] = []
49
+ const extensionMap: Record<string, number> = {}
50
+ const manifests: ManifestFile[] = []
51
+ const entryPoints: string[] = []
52
+ const envFiles: string[] = []
53
+ const testFiles: string[] = []
54
+
55
+ const directoryTree = await walkDirectory(absRoot, absRoot, 0, maxDepth, extraSkip, {
56
+ allFiles,
57
+ extensionMap,
58
+ manifests,
59
+ entryPoints,
60
+ envFiles,
61
+ testFiles,
62
+ })
63
+
64
+ const gitChurn = await collectGitChurn(absRoot)
65
+
66
+ const largeFiles = allFiles
67
+ .sort((a, b) => b.bytes - a.bytes)
68
+ .slice(0, 20)
69
+ .map(f => ({ path: f.path, bytes: f.bytes }))
70
+
71
+ return {
72
+ directoryTree,
73
+ extensionMap,
74
+ manifests,
75
+ entryPoints,
76
+ gitChurn,
77
+ largeFiles,
78
+ envFiles,
79
+ testFiles,
80
+ }
81
+ }
82
+
83
+ interface Collectors {
84
+ allFiles: { path: string; bytes: number }[]
85
+ extensionMap: Record<string, number>
86
+ manifests: ManifestFile[]
87
+ entryPoints: string[]
88
+ envFiles: string[]
89
+ testFiles: string[]
90
+ }
91
+
92
+ async function walkDirectory(
93
+ dir: string,
94
+ root: string,
95
+ depth: number,
96
+ maxDepth: number,
97
+ extraSkip: Set<string>,
98
+ collectors: Collectors,
99
+ ): Promise<DirectoryNode[]> {
100
+ if (depth > maxDepth) return []
101
+
102
+ let entries
103
+ try {
104
+ entries = await readdir(dir, { withFileTypes: true })
105
+ } catch {
106
+ return []
107
+ }
108
+
109
+ const nodes: DirectoryNode[] = []
110
+
111
+ for (const entry of entries) {
112
+ const fullPath = join(dir, entry.name)
113
+ const relPath = relative(root, fullPath)
114
+
115
+ if (entry.isDirectory()) {
116
+ if (SKIP_DIRS.has(entry.name) || extraSkip.has(entry.name)) continue
117
+
118
+ const children = await walkDirectory(fullPath, root, depth + 1, maxDepth, extraSkip, collectors)
119
+ nodes.push({ name: entry.name, path: relPath, type: 'directory', children })
120
+ } else if (entry.isFile()) {
121
+ nodes.push({ name: entry.name, path: relPath, type: 'file' })
122
+
123
+ let fileBytes = 0
124
+ try {
125
+ const s = await stat(fullPath)
126
+ fileBytes = s.size
127
+ } catch {
128
+ // skip
129
+ }
130
+
131
+ collectors.allFiles.push({ path: relPath, bytes: fileBytes })
132
+
133
+ const ext = extname(entry.name)
134
+ if (ext) {
135
+ collectors.extensionMap[ext] = (collectors.extensionMap[ext] ?? 0) + 1
136
+ }
137
+
138
+ // Manifest check
139
+ if (entry.name in MANIFEST_NAMES) {
140
+ collectors.manifests.push({
141
+ path: relPath,
142
+ type: MANIFEST_NAMES[entry.name]!,
143
+ depth,
144
+ })
145
+ }
146
+
147
+ // Entry point check — root or src/ level
148
+ if (depth <= 1 || (depth === 2 && dir.endsWith('/src'))) {
149
+ const nameWithoutExt = entry.name.replace(/\.\w+$/, '')
150
+ if (ENTRY_NAMES.has(nameWithoutExt)) {
151
+ collectors.entryPoints.push(relPath)
152
+ }
153
+ }
154
+
155
+ // Env files
156
+ if (entry.name.startsWith('.env')) {
157
+ collectors.envFiles.push(relPath)
158
+ }
159
+
160
+ // Config directories are handled at directory level
161
+ // But we also detect config files at root
162
+ if (depth === 0 && entry.name.match(/^config\./)) {
163
+ collectors.envFiles.push(relPath)
164
+ }
165
+
166
+ // Test files
167
+ const fileName = entry.name
168
+ if (TEST_PATTERNS.some(p => p.test(fileName))) {
169
+ collectors.testFiles.push(relPath)
170
+ }
171
+ }
172
+ }
173
+
174
+ // Check if this directory is a config directory
175
+ const dirName = dir.split('/').pop()
176
+ if (dirName === 'config' && depth <= 2) {
177
+ collectors.envFiles.push(relative(root, dir))
178
+ }
179
+
180
+ return nodes
181
+ }
182
+
183
+ async function collectGitChurn(root: string): Promise<ChurnEntry[]> {
184
+ try {
185
+ const { stdout } = await execFileAsync(
186
+ 'git',
187
+ ['log', '--stat', '--oneline', '-50', '--pretty=format:'],
188
+ { cwd: root, maxBuffer: 1024 * 1024 },
189
+ )
190
+
191
+ const changeCounts: Record<string, number> = {}
192
+
193
+ for (const line of stdout.split('\n')) {
194
+ // Match lines like: src/billing/service.ts | 42 +++---
195
+ const match = line.match(/^\s+(.+?)\s+\|\s+(\d+)/)
196
+ if (match) {
197
+ const filePath = match[1]!.trim()
198
+ const changes = parseInt(match[2]!, 10)
199
+ changeCounts[filePath] = (changeCounts[filePath] ?? 0) + changes
200
+ }
201
+ }
202
+
203
+ return Object.entries(changeCounts)
204
+ .sort(([, a], [, b]) => b - a)
205
+ .slice(0, 20)
206
+ .map(([path, changes]) => ({ path, changes }))
207
+ } catch {
208
+ // No git or git log fails — return empty
209
+ return []
210
+ }
211
+ }
212
+
213
+ // --- CLI interface ---
214
+
215
+ async function main(): Promise<void> {
216
+ const args = process.argv.slice(2)
217
+ let root = '.'
218
+ const exclude: string[] = []
219
+
220
+ for (let i = 0; i < args.length; i++) {
221
+ if (args[i] === '--root' && args[i + 1]) {
222
+ root = args[i + 1]!
223
+ i++
224
+ } else if (args[i] === '--exclude' && args[i + 1]) {
225
+ exclude.push(...args[i + 1]!.split(','))
226
+ i++
227
+ }
228
+ }
229
+
230
+ const result = await collectSignals(root, { exclude })
231
+ process.stdout.write(JSON.stringify(result, null, 2))
232
+ }
233
+
234
+ main().catch(err => {
235
+ process.stderr.write(JSON.stringify({ error: String(err) }))
236
+ process.exit(1)
237
+ })
@@ -0,0 +1,379 @@
1
+ import { readFile } from 'fs/promises'
2
+ import { resolve, extname, basename } from 'path'
3
+
4
+ const MAX_CLUSTER_LINES = 300
5
+
6
+ interface TrimOptions {
7
+ maxLines?: number
8
+ }
9
+
10
+ // Language-agnostic regex patterns for stripping function/method bodies
11
+ // Strategy: find opening braces after signatures, track depth, remove body content
12
+
13
+ function trimTypeScript(content: string): string {
14
+ const lines = content.split('\n')
15
+ const result: string[] = []
16
+ let braceDepth = 0
17
+ let inBody = false
18
+ let bodyStartDepth = 0
19
+
20
+ for (const line of lines) {
21
+ const trimmed = line.trim()
22
+
23
+ // Always keep: empty lines at top level, imports, type/interface, decorators, exports of types
24
+ if (braceDepth === 0 || !inBody) {
25
+ if (
26
+ trimmed === '' ||
27
+ trimmed.startsWith('import ') ||
28
+ trimmed.startsWith('export type ') ||
29
+ trimmed.startsWith('export interface ') ||
30
+ trimmed.startsWith('export enum ') ||
31
+ trimmed.startsWith('export const ') ||
32
+ trimmed.startsWith('type ') ||
33
+ trimmed.startsWith('interface ') ||
34
+ trimmed.startsWith('enum ') ||
35
+ trimmed.startsWith('@') ||
36
+ trimmed.startsWith('//') ||
37
+ trimmed.startsWith('/*') ||
38
+ trimmed.startsWith('*') ||
39
+ trimmed.startsWith('declare ')
40
+ ) {
41
+ result.push(line)
42
+ // Count braces even in kept lines
43
+ braceDepth += countChar(trimmed, '{') - countChar(trimmed, '}')
44
+ continue
45
+ }
46
+ }
47
+
48
+ const openBraces = countChar(trimmed, '{')
49
+ const closeBraces = countChar(trimmed, '}')
50
+
51
+ if (!inBody) {
52
+ // Detect function/method signature — line with opening brace
53
+ if (isSignatureLine(trimmed) && openBraces > closeBraces) {
54
+ result.push(line)
55
+ braceDepth += openBraces - closeBraces
56
+ inBody = true
57
+ bodyStartDepth = braceDepth
58
+ continue
59
+ }
60
+
61
+ // Class/interface declaration — keep but don't treat as body
62
+ if (isClassOrInterfaceLine(trimmed)) {
63
+ result.push(line)
64
+ braceDepth += openBraces - closeBraces
65
+ continue
66
+ }
67
+
68
+ // Keep the line (top-level statement, property declaration, etc.)
69
+ result.push(line)
70
+ braceDepth += openBraces - closeBraces
71
+ } else {
72
+ // Inside a function body — skip lines
73
+ braceDepth += openBraces - closeBraces
74
+
75
+ // Check if we've closed back to where the body started
76
+ if (braceDepth < bodyStartDepth) {
77
+ // Add closing brace
78
+ result.push(line)
79
+ inBody = false
80
+ }
81
+ }
82
+ }
83
+
84
+ return result.join('\n')
85
+ }
86
+
87
+ function trimPython(content: string): string {
88
+ const lines = content.split('\n')
89
+ const result: string[] = []
90
+ let skipIndent = -1
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i]!
94
+ const trimmed = line.trim()
95
+ const indent = line.length - line.trimStart().length
96
+
97
+ // If we're skipping a body and this line is still indented deeper, skip it
98
+ if (skipIndent >= 0) {
99
+ if (trimmed === '' || indent > skipIndent) {
100
+ continue
101
+ }
102
+ // We've exited the body
103
+ skipIndent = -1
104
+ }
105
+
106
+ // Always keep: comments, imports, class defs, decorators, type hints, module-level assignments
107
+ if (
108
+ trimmed === '' ||
109
+ trimmed.startsWith('#') ||
110
+ trimmed.startsWith('import ') ||
111
+ trimmed.startsWith('from ') ||
112
+ trimmed.startsWith('@') ||
113
+ trimmed.startsWith('class ') ||
114
+ /^[A-Z_][A-Z_0-9]*\s*=/.test(trimmed)
115
+ ) {
116
+ result.push(line)
117
+ continue
118
+ }
119
+
120
+ // Function/method definition — keep signature, skip body
121
+ if (trimmed.startsWith('def ') || trimmed.startsWith('async def ')) {
122
+ result.push(line)
123
+ // If the next non-empty line has docstring, keep it
124
+ const docIdx = findDocstring(lines, i + 1, indent)
125
+ if (docIdx > i) {
126
+ for (let j = i + 1; j <= docIdx; j++) {
127
+ result.push(lines[j]!)
128
+ }
129
+ }
130
+ skipIndent = indent
131
+ continue
132
+ }
133
+
134
+ // Keep everything else at module/class level
135
+ result.push(line)
136
+ }
137
+
138
+ return result.join('\n')
139
+ }
140
+
141
+ function trimJava(content: string): string {
142
+ // Java/Kotlin — very similar to TypeScript brace-matching
143
+ const lines = content.split('\n')
144
+ const result: string[] = []
145
+ let braceDepth = 0
146
+ let inBody = false
147
+ let bodyStartDepth = 0
148
+
149
+ for (const line of lines) {
150
+ const trimmed = line.trim()
151
+
152
+ if (braceDepth === 0 || !inBody) {
153
+ if (
154
+ trimmed === '' ||
155
+ trimmed.startsWith('import ') ||
156
+ trimmed.startsWith('package ') ||
157
+ trimmed.startsWith('@') ||
158
+ trimmed.startsWith('//') ||
159
+ trimmed.startsWith('/*') ||
160
+ trimmed.startsWith('*') ||
161
+ trimmed.startsWith('public interface ') ||
162
+ trimmed.startsWith('interface ') ||
163
+ trimmed.startsWith('public enum ') ||
164
+ trimmed.startsWith('enum ')
165
+ ) {
166
+ result.push(line)
167
+ braceDepth += countChar(trimmed, '{') - countChar(trimmed, '}')
168
+ continue
169
+ }
170
+ }
171
+
172
+ const openBraces = countChar(trimmed, '{')
173
+ const closeBraces = countChar(trimmed, '}')
174
+
175
+ if (!inBody) {
176
+ if (isJavaMethodSignature(trimmed) && openBraces > closeBraces) {
177
+ result.push(line)
178
+ braceDepth += openBraces - closeBraces
179
+ inBody = true
180
+ bodyStartDepth = braceDepth
181
+ continue
182
+ }
183
+
184
+ if (isJavaClassLine(trimmed)) {
185
+ result.push(line)
186
+ braceDepth += openBraces - closeBraces
187
+ continue
188
+ }
189
+
190
+ result.push(line)
191
+ braceDepth += openBraces - closeBraces
192
+ } else {
193
+ braceDepth += openBraces - closeBraces
194
+ if (braceDepth < bodyStartDepth) {
195
+ result.push(line)
196
+ inBody = false
197
+ }
198
+ }
199
+ }
200
+
201
+ return result.join('\n')
202
+ }
203
+
204
+ function trimGeneric(content: string): string {
205
+ // For unknown languages, just return as-is (truncation handles size)
206
+ return content
207
+ }
208
+
209
+ // --- Helpers ---
210
+
211
+ function countChar(s: string, ch: string): number {
212
+ let count = 0
213
+ let inString = false
214
+ let stringChar = ''
215
+ for (let i = 0; i < s.length; i++) {
216
+ const c = s[i]!
217
+ if (inString) {
218
+ if (c === stringChar && s[i - 1] !== '\\') inString = false
219
+ } else if (c === '"' || c === "'" || c === '`') {
220
+ inString = true
221
+ stringChar = c
222
+ } else if (c === ch) {
223
+ count++
224
+ }
225
+ }
226
+ return count
227
+ }
228
+
229
+ function isSignatureLine(trimmed: string): boolean {
230
+ return /^(export\s+)?(async\s+)?function\s/.test(trimmed) ||
231
+ /^(public|private|protected|static|async|get|set|\*)\s/.test(trimmed) ||
232
+ /^(readonly\s+)?[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/.test(trimmed) ||
233
+ /^(export\s+)?(const|let|var)\s+\w+\s*=\s*(async\s+)?\(/.test(trimmed) ||
234
+ /^(export\s+)?(const|let|var)\s+\w+\s*=\s*(async\s+)?function/.test(trimmed) ||
235
+ // Arrow function assigned at class level
236
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*(async\s+)?\(/.test(trimmed)
237
+ }
238
+
239
+ function isClassOrInterfaceLine(trimmed: string): boolean {
240
+ return /^(export\s+)?(abstract\s+)?(class|interface|enum)\s/.test(trimmed) ||
241
+ /^(export\s+)?namespace\s/.test(trimmed)
242
+ }
243
+
244
+ function isJavaMethodSignature(trimmed: string): boolean {
245
+ return /^(public|private|protected|static|final|abstract|synchronized|native)\s/.test(trimmed) &&
246
+ /\(/.test(trimmed)
247
+ }
248
+
249
+ function isJavaClassLine(trimmed: string): boolean {
250
+ return /^(public|private|protected)?\s*(abstract\s+)?(class|interface|enum)\s/.test(trimmed)
251
+ }
252
+
253
+ function findDocstring(lines: string[], startIdx: number, defIndent: number): number {
254
+ // Find Python docstring (triple-quoted) after a def
255
+ for (let i = startIdx; i < lines.length; i++) {
256
+ const trimmed = lines[i]!.trim()
257
+ if (trimmed === '') continue
258
+ if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
259
+ const quote = trimmed.slice(0, 3)
260
+ // Single-line docstring
261
+ if (trimmed.length > 3 && trimmed.endsWith(quote)) return i
262
+ // Multi-line docstring — find closing
263
+ for (let j = i + 1; j < lines.length; j++) {
264
+ if (lines[j]!.trim().endsWith(quote)) return j
265
+ }
266
+ return i
267
+ }
268
+ // First non-empty line after def is not a docstring
269
+ return startIdx - 1
270
+ }
271
+ return startIdx - 1
272
+ }
273
+
274
+ function getTrimmer(filePath: string): (content: string) => string {
275
+ const ext = extname(filePath).toLowerCase()
276
+ switch (ext) {
277
+ case '.ts':
278
+ case '.tsx':
279
+ case '.js':
280
+ case '.jsx':
281
+ case '.mjs':
282
+ case '.cjs':
283
+ return trimTypeScript
284
+ case '.py':
285
+ return trimPython
286
+ case '.java':
287
+ case '.kt':
288
+ case '.kts':
289
+ case '.scala':
290
+ case '.groovy':
291
+ return trimJava
292
+ case '.go':
293
+ case '.rs':
294
+ case '.c':
295
+ case '.cpp':
296
+ case '.h':
297
+ case '.hpp':
298
+ case '.cs':
299
+ case '.swift':
300
+ case '.dart':
301
+ return trimJava // brace-based languages use same strategy
302
+ default:
303
+ return trimGeneric
304
+ }
305
+ }
306
+
307
+ export async function trimCluster(paths: string[], projectRoot: string): Promise<string> {
308
+ const results: Array<{ path: string; content: string; lines: number }> = []
309
+
310
+ for (const filePath of paths) {
311
+ const absPath = resolve(projectRoot, filePath)
312
+ try {
313
+ const raw = await readFile(absPath, 'utf-8')
314
+ const trimmer = getTrimmer(filePath)
315
+ const trimmed = trimmer(raw)
316
+ results.push({
317
+ path: filePath,
318
+ content: trimmed,
319
+ lines: trimmed.split('\n').length,
320
+ })
321
+ } catch {
322
+ // Skip files that can't be read
323
+ if (process.env['NOGREP_DEBUG'] === '1') {
324
+ process.stderr.write(`[nogrep] Could not read: ${absPath}\n`)
325
+ }
326
+ }
327
+ }
328
+
329
+ // Sort by line count descending — truncate least important (largest) files first
330
+ results.sort((a, b) => a.lines - b.lines)
331
+
332
+ const output: string[] = []
333
+ let totalLines = 0
334
+ const maxLines = MAX_CLUSTER_LINES
335
+
336
+ for (const file of results) {
337
+ const header = `// === ${file.path} ===`
338
+ const fileLines = file.content.split('\n')
339
+ const available = maxLines - totalLines - 2 // header + separator
340
+
341
+ if (available <= 0) break
342
+
343
+ output.push(header)
344
+ if (fileLines.length <= available) {
345
+ output.push(file.content)
346
+ } else {
347
+ output.push(fileLines.slice(0, available).join('\n'))
348
+ output.push(`// ... truncated (${fileLines.length - available} more lines)`)
349
+ }
350
+ output.push('')
351
+
352
+ totalLines += Math.min(fileLines.length, available) + 2
353
+ }
354
+
355
+ return output.join('\n')
356
+ }
357
+
358
+ // --- CLI ---
359
+
360
+ async function main(): Promise<void> {
361
+ const args = process.argv.slice(2)
362
+
363
+ if (args.length === 0) {
364
+ process.stderr.write('Usage: node trim.js <path1> <path2> ...\n')
365
+ process.exit(1)
366
+ }
367
+
368
+ const projectRoot = process.cwd()
369
+ const result = await trimCluster(args, projectRoot)
370
+ process.stdout.write(result)
371
+ }
372
+
373
+ const isDirectRun = process.argv[1]?.endsWith('trim.js') || process.argv[1]?.endsWith('trim.ts')
374
+ if (isDirectRun) {
375
+ main().catch((err: unknown) => {
376
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`)
377
+ process.exit(1)
378
+ })
379
+ }