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.
- package/README.md +91 -0
- package/commands/init.md +241 -0
- package/commands/off.md +11 -0
- package/commands/on.md +21 -0
- package/commands/query.md +13 -0
- package/commands/status.md +15 -0
- package/commands/update.md +89 -0
- package/dist/chunk-SMUAF6SM.js +12 -0
- package/dist/chunk-SMUAF6SM.js.map +1 -0
- package/dist/query.d.ts +12 -0
- package/dist/query.js +272 -0
- package/dist/query.js.map +1 -0
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +75 -0
- package/dist/settings.js.map +1 -0
- package/dist/signals.d.ts +9 -0
- package/dist/signals.js +174 -0
- package/dist/signals.js.map +1 -0
- package/dist/trim.d.ts +3 -0
- package/dist/trim.js +266 -0
- package/dist/trim.js.map +1 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.js +143 -0
- package/dist/validate.js.map +1 -0
- package/dist/write.d.ts +8 -0
- package/dist/write.js +267 -0
- package/dist/write.js.map +1 -0
- package/docs/ARCHITECTURE.md +239 -0
- package/docs/CLAUDE.md +161 -0
- package/docs/CONVENTIONS.md +162 -0
- package/docs/SPEC.md +803 -0
- package/docs/TASKS.md +216 -0
- package/hooks/hooks.json +35 -0
- package/hooks/pre-tool-use.sh +37 -0
- package/hooks/prompt-submit.sh +26 -0
- package/hooks/session-start.sh +21 -0
- package/package.json +24 -0
- package/scripts/query.ts +290 -0
- package/scripts/settings.ts +98 -0
- package/scripts/signals.ts +237 -0
- package/scripts/trim.ts +379 -0
- package/scripts/types.ts +186 -0
- package/scripts/validate.ts +181 -0
- package/scripts/write.ts +346 -0
- 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
|
+
})
|
package/scripts/trim.ts
ADDED
|
@@ -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
|
+
}
|