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
package/scripts/types.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// --- Directory / File types ---
|
|
2
|
+
|
|
3
|
+
export interface DirectoryNode {
|
|
4
|
+
name: string
|
|
5
|
+
path: string
|
|
6
|
+
type: 'file' | 'directory'
|
|
7
|
+
children?: DirectoryNode[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ManifestFile {
|
|
11
|
+
path: string
|
|
12
|
+
type: string
|
|
13
|
+
depth: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChurnEntry {
|
|
17
|
+
path: string
|
|
18
|
+
changes: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FileSize {
|
|
22
|
+
path: string
|
|
23
|
+
bytes: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Signal collection ---
|
|
27
|
+
|
|
28
|
+
export interface SignalResult {
|
|
29
|
+
directoryTree: DirectoryNode[]
|
|
30
|
+
extensionMap: Record<string, number>
|
|
31
|
+
manifests: ManifestFile[]
|
|
32
|
+
entryPoints: string[]
|
|
33
|
+
gitChurn: ChurnEntry[]
|
|
34
|
+
largeFiles: FileSize[]
|
|
35
|
+
envFiles: string[]
|
|
36
|
+
testFiles: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Stack detection ---
|
|
40
|
+
|
|
41
|
+
export interface StackConventions {
|
|
42
|
+
entryPattern: string
|
|
43
|
+
testPattern: string
|
|
44
|
+
configLocation: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DomainCluster {
|
|
48
|
+
name: string
|
|
49
|
+
path: string
|
|
50
|
+
confidence: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StackResult {
|
|
54
|
+
primaryLanguage: string
|
|
55
|
+
frameworks: string[]
|
|
56
|
+
architecture: 'monolith' | 'monorepo' | 'multi-repo' | 'microservice' | 'library'
|
|
57
|
+
domainClusters: DomainCluster[]
|
|
58
|
+
conventions: StackConventions
|
|
59
|
+
stackHints: string
|
|
60
|
+
dynamicTaxonomy: { domain: string[]; tech: string[] }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Tags ---
|
|
64
|
+
|
|
65
|
+
export interface TagSet {
|
|
66
|
+
domain: string[]
|
|
67
|
+
layer: string[]
|
|
68
|
+
tech: string[]
|
|
69
|
+
concern: string[]
|
|
70
|
+
type: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Taxonomy {
|
|
74
|
+
static: {
|
|
75
|
+
layer: string[]
|
|
76
|
+
concern: string[]
|
|
77
|
+
type: string[]
|
|
78
|
+
}
|
|
79
|
+
dynamic: {
|
|
80
|
+
domain: string[]
|
|
81
|
+
tech: string[]
|
|
82
|
+
}
|
|
83
|
+
custom: Record<string, string[]>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Relations ---
|
|
87
|
+
|
|
88
|
+
export interface Relation {
|
|
89
|
+
id: string
|
|
90
|
+
reason: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ExternalDep {
|
|
94
|
+
name: string
|
|
95
|
+
usage: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SyncMeta {
|
|
99
|
+
commit: string
|
|
100
|
+
timestamp: string
|
|
101
|
+
srcHash: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Context nodes ---
|
|
105
|
+
|
|
106
|
+
export interface NodeResult {
|
|
107
|
+
id: string
|
|
108
|
+
title: string
|
|
109
|
+
category: 'domain' | 'architecture' | 'flow' | 'entity'
|
|
110
|
+
tags: TagSet
|
|
111
|
+
relatesTo: Relation[]
|
|
112
|
+
inverseRelations: Relation[]
|
|
113
|
+
srcPaths: string[]
|
|
114
|
+
keywords: string[]
|
|
115
|
+
lastSynced: SyncMeta
|
|
116
|
+
purpose: string
|
|
117
|
+
publicSurface: string[]
|
|
118
|
+
doesNotOwn: string[]
|
|
119
|
+
externalDeps: ExternalDep[]
|
|
120
|
+
gotchas: string[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Index ---
|
|
124
|
+
|
|
125
|
+
export interface PathEntry {
|
|
126
|
+
context: string
|
|
127
|
+
tags: string[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface IndexJson {
|
|
131
|
+
version: string
|
|
132
|
+
generatedAt: string
|
|
133
|
+
commit: string
|
|
134
|
+
stack: Pick<StackResult, 'primaryLanguage' | 'frameworks' | 'architecture'>
|
|
135
|
+
tags: Record<string, string[]>
|
|
136
|
+
keywords: Record<string, string[]>
|
|
137
|
+
paths: Record<string, PathEntry>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Registry ---
|
|
141
|
+
|
|
142
|
+
export interface RegistryMapping {
|
|
143
|
+
glob: string
|
|
144
|
+
contextFile: string
|
|
145
|
+
watch: boolean
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface RegistryJson {
|
|
149
|
+
mappings: RegistryMapping[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Query ---
|
|
153
|
+
|
|
154
|
+
export interface RankedResult {
|
|
155
|
+
contextFile: string
|
|
156
|
+
score: number
|
|
157
|
+
matchedOn: string[]
|
|
158
|
+
summary: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Validation ---
|
|
162
|
+
|
|
163
|
+
export interface StaleResult {
|
|
164
|
+
file: string
|
|
165
|
+
isStale: boolean
|
|
166
|
+
reason?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Settings ---
|
|
170
|
+
|
|
171
|
+
export interface NogrepSettings {
|
|
172
|
+
enabled: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Errors ---
|
|
176
|
+
|
|
177
|
+
export type NogrepErrorCode = 'NO_INDEX' | 'NO_GIT' | 'IO_ERROR' | 'STALE'
|
|
178
|
+
|
|
179
|
+
export class NogrepError extends Error {
|
|
180
|
+
constructor(
|
|
181
|
+
message: string,
|
|
182
|
+
public code: NogrepErrorCode,
|
|
183
|
+
) {
|
|
184
|
+
super(message)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve as resolvePath } from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { glob } from 'glob'
|
|
6
|
+
import matter from 'gray-matter'
|
|
7
|
+
import type { StaleResult } from './types.js'
|
|
8
|
+
import { NogrepError } from './types.js'
|
|
9
|
+
|
|
10
|
+
// --- Freshness check ---
|
|
11
|
+
|
|
12
|
+
export async function checkFreshness(
|
|
13
|
+
nodeFile: string,
|
|
14
|
+
projectRoot: string,
|
|
15
|
+
): Promise<StaleResult> {
|
|
16
|
+
let content: string
|
|
17
|
+
try {
|
|
18
|
+
content = await readFile(join(projectRoot, nodeFile), 'utf-8')
|
|
19
|
+
} catch {
|
|
20
|
+
return { file: nodeFile, isStale: true, reason: 'context file not found' }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parsed = matter(content)
|
|
24
|
+
const srcPaths: string[] = parsed.data.src_paths ?? []
|
|
25
|
+
const lastSynced = parsed.data.last_synced as
|
|
26
|
+
| { src_hash?: string; commit?: string; timestamp?: string }
|
|
27
|
+
| undefined
|
|
28
|
+
|
|
29
|
+
if (!lastSynced?.src_hash) {
|
|
30
|
+
return { file: nodeFile, isStale: true, reason: 'no src_hash in frontmatter' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (srcPaths.length === 0) {
|
|
34
|
+
return { file: nodeFile, isStale: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Glob all matching source files
|
|
38
|
+
const allFiles: string[] = []
|
|
39
|
+
for (const pattern of srcPaths) {
|
|
40
|
+
const matches = await glob(pattern, {
|
|
41
|
+
cwd: projectRoot,
|
|
42
|
+
nodir: true,
|
|
43
|
+
ignore: ['node_modules/**', 'dist/**', 'build/**', '.git/**', 'coverage/**'],
|
|
44
|
+
})
|
|
45
|
+
allFiles.push(...matches)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
allFiles.sort()
|
|
49
|
+
|
|
50
|
+
if (allFiles.length === 0) {
|
|
51
|
+
return { file: nodeFile, isStale: true, reason: 'no source files match src_paths' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compute SHA256 of all file contents concatenated
|
|
55
|
+
const hash = createHash('sha256')
|
|
56
|
+
for (const file of allFiles) {
|
|
57
|
+
try {
|
|
58
|
+
const fileContent = await readFile(join(projectRoot, file))
|
|
59
|
+
hash.update(fileContent)
|
|
60
|
+
} catch {
|
|
61
|
+
// File unreadable — skip
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const currentHash = `sha256:${hash.digest('hex').slice(0, 12)}`
|
|
65
|
+
|
|
66
|
+
if (currentHash !== lastSynced.src_hash) {
|
|
67
|
+
return {
|
|
68
|
+
file: nodeFile,
|
|
69
|
+
isStale: true,
|
|
70
|
+
reason: `hash mismatch: expected ${lastSynced.src_hash}, got ${currentHash}`,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { file: nodeFile, isStale: false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Discover all context nodes ---
|
|
78
|
+
|
|
79
|
+
async function discoverNodes(projectRoot: string): Promise<string[]> {
|
|
80
|
+
const nogrepDir = join(projectRoot, '.nogrep')
|
|
81
|
+
const patterns = [
|
|
82
|
+
'domains/*.md',
|
|
83
|
+
'architecture/*.md',
|
|
84
|
+
'flows/*.md',
|
|
85
|
+
'entities/*.md',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
const files: string[] = []
|
|
89
|
+
for (const pattern of patterns) {
|
|
90
|
+
const matches = await glob(pattern, { cwd: nogrepDir, nodir: true })
|
|
91
|
+
files.push(...matches.map(m => `.nogrep/${m}`))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return files.sort()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Validate all nodes ---
|
|
98
|
+
|
|
99
|
+
export async function validateAll(
|
|
100
|
+
projectRoot: string,
|
|
101
|
+
): Promise<{ total: number; fresh: StaleResult[]; stale: StaleResult[] }> {
|
|
102
|
+
const indexPath = join(projectRoot, '.nogrep', '_index.json')
|
|
103
|
+
try {
|
|
104
|
+
await readFile(indexPath, 'utf-8')
|
|
105
|
+
} catch {
|
|
106
|
+
throw new NogrepError(
|
|
107
|
+
'No .nogrep/_index.json found. Run /nogrep:init first.',
|
|
108
|
+
'NO_INDEX',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const nodeFiles = await discoverNodes(projectRoot)
|
|
113
|
+
const results = await Promise.all(
|
|
114
|
+
nodeFiles.map(f => checkFreshness(f, projectRoot)),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const fresh = results.filter(r => !r.isStale)
|
|
118
|
+
const stale = results.filter(r => r.isStale)
|
|
119
|
+
|
|
120
|
+
return { total: results.length, fresh, stale }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Formatting ---
|
|
124
|
+
|
|
125
|
+
function formatText(result: { total: number; fresh: StaleResult[]; stale: StaleResult[] }): string {
|
|
126
|
+
const lines: string[] = []
|
|
127
|
+
lines.push(`nogrep index: ${result.total} nodes`)
|
|
128
|
+
lines.push(` Fresh: ${result.fresh.length}`)
|
|
129
|
+
lines.push(` Stale: ${result.stale.length}`)
|
|
130
|
+
|
|
131
|
+
if (result.stale.length > 0) {
|
|
132
|
+
lines.push('')
|
|
133
|
+
lines.push('Stale nodes:')
|
|
134
|
+
for (const s of result.stale) {
|
|
135
|
+
lines.push(` - ${s.file}: ${s.reason}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatJson(result: { total: number; fresh: StaleResult[]; stale: StaleResult[] }): string {
|
|
143
|
+
return JSON.stringify(result, null, 2)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- CLI ---
|
|
147
|
+
|
|
148
|
+
async function main(): Promise<void> {
|
|
149
|
+
const { values } = parseArgs({
|
|
150
|
+
options: {
|
|
151
|
+
format: { type: 'string', default: 'text' },
|
|
152
|
+
root: { type: 'string', default: process.cwd() },
|
|
153
|
+
},
|
|
154
|
+
strict: true,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const root = resolvePath(values.root ?? process.cwd())
|
|
158
|
+
const format = values.format ?? 'text'
|
|
159
|
+
|
|
160
|
+
const result = await validateAll(root)
|
|
161
|
+
|
|
162
|
+
switch (format) {
|
|
163
|
+
case 'json':
|
|
164
|
+
process.stdout.write(formatJson(result) + '\n')
|
|
165
|
+
break
|
|
166
|
+
case 'text':
|
|
167
|
+
default:
|
|
168
|
+
process.stdout.write(formatText(result) + '\n')
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
main().catch((err: unknown) => {
|
|
174
|
+
if (err instanceof NogrepError) {
|
|
175
|
+
process.stderr.write(JSON.stringify({ error: err.message, code: err.code }) + '\n')
|
|
176
|
+
} else {
|
|
177
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
178
|
+
process.stderr.write(JSON.stringify({ error: message }) + '\n')
|
|
179
|
+
}
|
|
180
|
+
process.exitCode = 1
|
|
181
|
+
})
|
package/scripts/write.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve, dirname } from 'node:path'
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { glob } from 'glob'
|
|
7
|
+
import matter from 'gray-matter'
|
|
8
|
+
import yaml from 'js-yaml'
|
|
9
|
+
import type {
|
|
10
|
+
NodeResult,
|
|
11
|
+
StackResult,
|
|
12
|
+
IndexJson,
|
|
13
|
+
RegistryJson,
|
|
14
|
+
PathEntry,
|
|
15
|
+
NogrepError,
|
|
16
|
+
} from './types.js'
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
|
|
20
|
+
// --- Manual Notes preservation ---
|
|
21
|
+
|
|
22
|
+
function extractManualNotes(content: string): string {
|
|
23
|
+
const match = content.match(
|
|
24
|
+
/## Manual Notes\n([\s\S]*?)(?=\n## |\n---|\s*$)/,
|
|
25
|
+
)
|
|
26
|
+
return match ? match[1]!.trim() : ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Context node markdown generation ---
|
|
30
|
+
|
|
31
|
+
function buildNodeFrontmatter(node: NodeResult): Record<string, unknown> {
|
|
32
|
+
return {
|
|
33
|
+
id: node.id,
|
|
34
|
+
title: node.title,
|
|
35
|
+
category: node.category,
|
|
36
|
+
tags: {
|
|
37
|
+
domain: node.tags.domain,
|
|
38
|
+
layer: node.tags.layer,
|
|
39
|
+
tech: node.tags.tech,
|
|
40
|
+
concern: node.tags.concern,
|
|
41
|
+
type: node.tags.type,
|
|
42
|
+
},
|
|
43
|
+
relates_to: node.relatesTo.map(r => ({ id: r.id, reason: r.reason })),
|
|
44
|
+
inverse_relations: node.inverseRelations.map(r => ({ id: r.id, reason: r.reason })),
|
|
45
|
+
src_paths: node.srcPaths,
|
|
46
|
+
keywords: node.keywords,
|
|
47
|
+
last_synced: {
|
|
48
|
+
commit: node.lastSynced.commit,
|
|
49
|
+
timestamp: node.lastSynced.timestamp,
|
|
50
|
+
src_hash: node.lastSynced.srcHash,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildNodeMarkdown(node: NodeResult, manualNotes: string): string {
|
|
56
|
+
const fm = buildNodeFrontmatter(node)
|
|
57
|
+
const yamlStr = yaml.dump(fm, { lineWidth: -1, quotingType: '"', forceQuotes: false })
|
|
58
|
+
|
|
59
|
+
const sections: string[] = []
|
|
60
|
+
sections.push(`---\n${yamlStr.trimEnd()}\n---`)
|
|
61
|
+
|
|
62
|
+
sections.push(`\n## Purpose\n${node.purpose}`)
|
|
63
|
+
|
|
64
|
+
if (node.publicSurface.length > 0) {
|
|
65
|
+
sections.push(`\n## Public Surface\n\n\`\`\`\n${node.publicSurface.join('\n')}\n\`\`\``)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (node.doesNotOwn.length > 0) {
|
|
69
|
+
sections.push(`\n## Does Not Own\n${node.doesNotOwn.map(d => `- ${d}`).join('\n')}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (node.gotchas.length > 0) {
|
|
73
|
+
sections.push(`\n## Gotchas\n${node.gotchas.map(g => `- ${g}`).join('\n')}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const notesContent = manualNotes || '_Human annotations. Never overwritten by nogrep update._'
|
|
77
|
+
sections.push(`\n## Manual Notes\n${notesContent}`)
|
|
78
|
+
|
|
79
|
+
return sections.join('\n') + '\n'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Write context files ---
|
|
83
|
+
|
|
84
|
+
function categoryDir(category: NodeResult['category']): string {
|
|
85
|
+
switch (category) {
|
|
86
|
+
case 'domain': return 'domains'
|
|
87
|
+
case 'architecture': return 'architecture'
|
|
88
|
+
case 'flow': return 'flows'
|
|
89
|
+
case 'entity': return 'entities'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function writeContextNodes(
|
|
94
|
+
nodes: NodeResult[],
|
|
95
|
+
outputDir: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
for (const node of nodes) {
|
|
98
|
+
const dir = join(outputDir, categoryDir(node.category))
|
|
99
|
+
await mkdir(dir, { recursive: true })
|
|
100
|
+
|
|
101
|
+
const filePath = join(dir, `${node.id}.md`)
|
|
102
|
+
|
|
103
|
+
// Preserve existing manual notes
|
|
104
|
+
let manualNotes = ''
|
|
105
|
+
try {
|
|
106
|
+
const existing = await readFile(filePath, 'utf-8')
|
|
107
|
+
manualNotes = extractManualNotes(existing)
|
|
108
|
+
} catch {
|
|
109
|
+
// File doesn't exist yet
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const content = buildNodeMarkdown(node, manualNotes)
|
|
113
|
+
await writeFile(filePath, content, 'utf-8')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Build index ---
|
|
118
|
+
|
|
119
|
+
export function buildIndex(
|
|
120
|
+
nodes: NodeResult[],
|
|
121
|
+
stack: Pick<StackResult, 'primaryLanguage' | 'frameworks' | 'architecture'>,
|
|
122
|
+
): IndexJson {
|
|
123
|
+
const tags: Record<string, string[]> = {}
|
|
124
|
+
const keywords: Record<string, string[]> = {}
|
|
125
|
+
const paths: Record<string, PathEntry> = {}
|
|
126
|
+
|
|
127
|
+
// Populate inverse relations
|
|
128
|
+
const inverseMap = new Map<string, Array<{ fromId: string; reason: string }>>()
|
|
129
|
+
for (const node of nodes) {
|
|
130
|
+
for (const rel of node.relatesTo) {
|
|
131
|
+
const existing = inverseMap.get(rel.id) ?? []
|
|
132
|
+
existing.push({ fromId: node.id, reason: rel.reason })
|
|
133
|
+
inverseMap.set(rel.id, existing)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
const contextFile = `.nogrep/${categoryDir(node.category)}/${node.id}.md`
|
|
139
|
+
|
|
140
|
+
// Merge inverse relations from the map
|
|
141
|
+
const inverseEntries = inverseMap.get(node.id) ?? []
|
|
142
|
+
for (const inv of inverseEntries) {
|
|
143
|
+
if (!node.inverseRelations.some(r => r.id === inv.fromId)) {
|
|
144
|
+
node.inverseRelations.push({ id: inv.fromId, reason: inv.reason })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Build tag index
|
|
149
|
+
const tagCategories: Array<[string, string[]]> = [
|
|
150
|
+
['domain', node.tags.domain],
|
|
151
|
+
['layer', node.tags.layer],
|
|
152
|
+
['tech', node.tags.tech],
|
|
153
|
+
['concern', node.tags.concern],
|
|
154
|
+
['type', node.tags.type],
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
const flatTags: string[] = []
|
|
158
|
+
for (const [cat, values] of tagCategories) {
|
|
159
|
+
for (const val of values) {
|
|
160
|
+
const tagKey = `${cat}:${val}`
|
|
161
|
+
flatTags.push(tagKey)
|
|
162
|
+
const tagList = tags[tagKey] ?? []
|
|
163
|
+
if (!tagList.includes(contextFile)) {
|
|
164
|
+
tagList.push(contextFile)
|
|
165
|
+
}
|
|
166
|
+
tags[tagKey] = tagList
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build keyword index
|
|
171
|
+
for (const kw of node.keywords) {
|
|
172
|
+
const kwList = keywords[kw] ?? []
|
|
173
|
+
if (!kwList.includes(contextFile)) {
|
|
174
|
+
kwList.push(contextFile)
|
|
175
|
+
}
|
|
176
|
+
keywords[kw] = kwList
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build path index
|
|
180
|
+
for (const srcPath of node.srcPaths) {
|
|
181
|
+
paths[srcPath] = { context: contextFile, tags: flatTags }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let commit = ''
|
|
186
|
+
try {
|
|
187
|
+
// Sync exec not ideal but this is a one-time build step
|
|
188
|
+
// We'll set it from the caller if available
|
|
189
|
+
} catch {
|
|
190
|
+
// no git
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
version: '1.0',
|
|
195
|
+
generatedAt: new Date().toISOString(),
|
|
196
|
+
commit,
|
|
197
|
+
stack: {
|
|
198
|
+
primaryLanguage: stack.primaryLanguage,
|
|
199
|
+
frameworks: stack.frameworks,
|
|
200
|
+
architecture: stack.architecture,
|
|
201
|
+
},
|
|
202
|
+
tags,
|
|
203
|
+
keywords,
|
|
204
|
+
paths,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Build registry ---
|
|
209
|
+
|
|
210
|
+
export function buildRegistry(nodes: NodeResult[]): RegistryJson {
|
|
211
|
+
const mappings = nodes.flatMap(node =>
|
|
212
|
+
node.srcPaths.map(srcPath => ({
|
|
213
|
+
glob: srcPath,
|
|
214
|
+
contextFile: `.nogrep/${categoryDir(node.category)}/${node.id}.md`,
|
|
215
|
+
watch: true,
|
|
216
|
+
})),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return { mappings }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Patch CLAUDE.md ---
|
|
223
|
+
|
|
224
|
+
export async function patchClaudeMd(projectRoot: string): Promise<void> {
|
|
225
|
+
const claudeMdPath = join(projectRoot, 'CLAUDE.md')
|
|
226
|
+
const patchPath = join(dirname(import.meta.url.replace('file://', '')), '..', 'templates', 'claude-md-patch.md')
|
|
227
|
+
|
|
228
|
+
let patch: string
|
|
229
|
+
try {
|
|
230
|
+
patch = await readFile(patchPath, 'utf-8')
|
|
231
|
+
} catch {
|
|
232
|
+
// Fallback: inline the patch content
|
|
233
|
+
patch = [
|
|
234
|
+
'<!-- nogrep -->',
|
|
235
|
+
'## Code Navigation',
|
|
236
|
+
'',
|
|
237
|
+
'This project uses [nogrep](https://github.com/techtulp/nogrep).',
|
|
238
|
+
'Context files in `.nogrep/` are a navigable index of this codebase.',
|
|
239
|
+
'When you see nogrep results injected into your context, trust them —',
|
|
240
|
+
'read those files before exploring source.',
|
|
241
|
+
'<!-- /nogrep -->',
|
|
242
|
+
].join('\n') + '\n'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let existing = ''
|
|
246
|
+
try {
|
|
247
|
+
existing = await readFile(claudeMdPath, 'utf-8')
|
|
248
|
+
} catch {
|
|
249
|
+
// File doesn't exist
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check for existing nogrep marker
|
|
253
|
+
if (existing.includes('<!-- nogrep -->')) {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const newContent = existing
|
|
258
|
+
? existing.trimEnd() + '\n\n' + patch
|
|
259
|
+
: patch
|
|
260
|
+
|
|
261
|
+
await writeFile(claudeMdPath, newContent, 'utf-8')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Write all outputs ---
|
|
265
|
+
|
|
266
|
+
interface WriteInput {
|
|
267
|
+
nodes: NodeResult[]
|
|
268
|
+
stack: Pick<StackResult, 'primaryLanguage' | 'frameworks' | 'architecture'>
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function writeAll(input: WriteInput, projectRoot: string): Promise<void> {
|
|
272
|
+
const outputDir = join(projectRoot, '.nogrep')
|
|
273
|
+
await mkdir(outputDir, { recursive: true })
|
|
274
|
+
|
|
275
|
+
// Write context node files
|
|
276
|
+
await writeContextNodes(input.nodes, outputDir)
|
|
277
|
+
|
|
278
|
+
// Build and write index
|
|
279
|
+
const index = buildIndex(input.nodes, input.stack)
|
|
280
|
+
|
|
281
|
+
// Try to get current git commit
|
|
282
|
+
try {
|
|
283
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
284
|
+
cwd: projectRoot,
|
|
285
|
+
})
|
|
286
|
+
index.commit = stdout.trim()
|
|
287
|
+
} catch {
|
|
288
|
+
// no git
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await writeFile(
|
|
292
|
+
join(outputDir, '_index.json'),
|
|
293
|
+
JSON.stringify(index, null, 2) + '\n',
|
|
294
|
+
'utf-8',
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
// Build and write registry
|
|
298
|
+
const registry = buildRegistry(input.nodes)
|
|
299
|
+
await writeFile(
|
|
300
|
+
join(outputDir, '_registry.json'),
|
|
301
|
+
JSON.stringify(registry, null, 2) + '\n',
|
|
302
|
+
'utf-8',
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Patch CLAUDE.md
|
|
306
|
+
await patchClaudeMd(projectRoot)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- CLI interface ---
|
|
310
|
+
|
|
311
|
+
async function main(): Promise<void> {
|
|
312
|
+
const args = process.argv.slice(2)
|
|
313
|
+
let inputFile: string | undefined
|
|
314
|
+
let projectRoot = process.cwd()
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < args.length; i++) {
|
|
317
|
+
if (args[i] === '--input' && args[i + 1]) {
|
|
318
|
+
inputFile = args[i + 1]!
|
|
319
|
+
i++
|
|
320
|
+
} else if (args[i] === '--root' && args[i + 1]) {
|
|
321
|
+
projectRoot = resolve(args[i + 1]!)
|
|
322
|
+
i++
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let rawInput: string
|
|
327
|
+
if (inputFile) {
|
|
328
|
+
rawInput = await readFile(resolve(inputFile), 'utf-8')
|
|
329
|
+
} else {
|
|
330
|
+
// Read from stdin
|
|
331
|
+
const chunks: Buffer[] = []
|
|
332
|
+
for await (const chunk of process.stdin) {
|
|
333
|
+
chunks.push(chunk as Buffer)
|
|
334
|
+
}
|
|
335
|
+
rawInput = Buffer.concat(chunks).toString('utf-8')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const input = JSON.parse(rawInput) as WriteInput
|
|
339
|
+
await writeAll(input, projectRoot)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
main().catch((err: unknown) => {
|
|
343
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
344
|
+
process.stderr.write(JSON.stringify({ error: message }) + '\n')
|
|
345
|
+
process.exitCode = 1
|
|
346
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!-- nogrep -->
|
|
2
|
+
## Code Navigation
|
|
3
|
+
|
|
4
|
+
This project uses [nogrep](https://github.com/techtulp/nogrep).
|
|
5
|
+
Context files in `.nogrep/` are a navigable index of this codebase.
|
|
6
|
+
When you see nogrep results injected into your context, trust them —
|
|
7
|
+
read those files before exploring source.
|
|
8
|
+
<!-- /nogrep -->
|