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/docs/TASKS.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# nogrep — Implementation Tasks
|
|
2
|
+
|
|
3
|
+
> Work through these tasks in order. Each task is independently testable before moving on.
|
|
4
|
+
> Read docs/CLAUDE.md, docs/SPEC.md, docs/ARCHITECTURE.md, and docs/CONVENTIONS.md before starting.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Task 1 — Project Scaffold
|
|
9
|
+
|
|
10
|
+
**Goal:** Buildable TypeScript project with plugin manifest.
|
|
11
|
+
|
|
12
|
+
- [x] Create `package.json` with dependencies: `glob`, `gray-matter`, `js-yaml`
|
|
13
|
+
- [x] Create `package.json` devDependencies: `typescript`, `tsup`, `vitest`, `@types/node`
|
|
14
|
+
- [x] Create `tsconfig.json` (see docs/CONVENTIONS.md)
|
|
15
|
+
- [x] Create `tsup.config.ts` — builds `scripts/` → `dist/`, ESM, declaration files
|
|
16
|
+
- [x] Create `scripts/types.ts` — all types from docs/ARCHITECTURE.md key types section
|
|
17
|
+
- [x] Create `plugin.json` — CC plugin manifest with hook declarations
|
|
18
|
+
- [x] Verify: `npm run build` compiles successfully
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Task 2 — Settings
|
|
23
|
+
|
|
24
|
+
**Goal:** Read/write nogrep settings from `.claude/settings.json` and `.claude/settings.local.json`.
|
|
25
|
+
|
|
26
|
+
- [x] Create `scripts/settings.ts`
|
|
27
|
+
- `readSettings(projectRoot)` — reads both files, local takes precedence over shared
|
|
28
|
+
- `writeSettings(projectRoot, settings, local?)` — writes to shared or local file
|
|
29
|
+
- Creates `.claude/` dir if it doesn't exist
|
|
30
|
+
- CLI interface: `node settings.js --set enabled=true [--local]` / `node settings.js --get`
|
|
31
|
+
- [x] Create `commands/on.md` slash command
|
|
32
|
+
- Runs `node "${CLAUDE_PLUGIN_ROOT}/dist/settings.js" --set enabled=true`
|
|
33
|
+
- Checks if `.nogrep/_index.json` exists
|
|
34
|
+
- If missing: suggests running `/nogrep:init`
|
|
35
|
+
- [x] Create `commands/off.md` slash command
|
|
36
|
+
- Runs `node "${CLAUDE_PLUGIN_ROOT}/dist/settings.js" --set enabled=false`
|
|
37
|
+
- [x] Write `tests/settings.test.ts` — test merge logic, local precedence, file creation
|
|
38
|
+
- [x] Verify: `/nogrep:on` and `/nogrep:off` work in CC
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Task 3 — Phase 1: Universal Signals
|
|
43
|
+
|
|
44
|
+
**Goal:** Collect language-agnostic signals from any project directory.
|
|
45
|
+
|
|
46
|
+
- [x] Create `scripts/signals.ts`
|
|
47
|
+
- `collectSignals(root, options)` → `SignalResult`
|
|
48
|
+
- Walk directory tree (depth 4, skip: node_modules, dist, build, .git, coverage)
|
|
49
|
+
- Group files by extension → `extensionMap`
|
|
50
|
+
- Find dependency manifests: `package.json`, `requirements.txt`, `pom.xml`, `go.mod`, `Podfile`, `Cargo.toml`, `pubspec.yaml`, `composer.json`
|
|
51
|
+
- Find entry points: files named `main.*`, `index.*`, `app.*`, `server.*` at root or src/ level
|
|
52
|
+
- Run `git log --stat --oneline -50` → parse top 20 most changed files
|
|
53
|
+
- Find top 20 largest files (excluding node_modules etc)
|
|
54
|
+
- Find `.env*` files and `config/` directories
|
|
55
|
+
- Find test files matching `*.test.*`, `*.spec.*`, `*_test.*`, `test_*.py`
|
|
56
|
+
- CLI interface: `node signals.js [--root <path>] [--exclude <globs>]` → JSON stdout
|
|
57
|
+
- [x] Create `tests/fixtures/nestjs-project/` — minimal NestJS project (5-10 files)
|
|
58
|
+
- [x] Create `tests/fixtures/django-project/` — minimal Django project
|
|
59
|
+
- [x] Create `tests/fixtures/react-project/` — minimal React project
|
|
60
|
+
- [x] Write `tests/signals.test.ts` — run against all 3 fixtures, assert signal shape
|
|
61
|
+
- [x] Verify: signals correctly identifies NestJS vs Django vs React
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Task 4 — Source Trimming
|
|
66
|
+
|
|
67
|
+
**Goal:** Reduce source files to signatures only, language-agnostic.
|
|
68
|
+
|
|
69
|
+
- [x] Create `scripts/trim.ts`
|
|
70
|
+
- `trimCluster(paths: string[], projectRoot: string)` → `string`
|
|
71
|
+
- For each file in the cluster's src_paths:
|
|
72
|
+
- Read file content
|
|
73
|
+
- Remove function/method bodies (keep signature line + opening brace only)
|
|
74
|
+
- Keep: file header comments, imports, class/interface declarations, decorators/annotations, exported symbols, type definitions
|
|
75
|
+
- Strip: function bodies, private method bodies, inline HTML/template strings
|
|
76
|
+
- Max 300 lines total across all files — truncate least important files first
|
|
77
|
+
- Strategy: regex-based (simple, universal — not perfect but good enough)
|
|
78
|
+
- CLI interface: `node trim.js <path1> <path2> ...` → trimmed output to stdout
|
|
79
|
+
- [x] Write `tests/trim.test.ts` — test against TypeScript, Python, Java snippet fixtures
|
|
80
|
+
- [x] Verify: trimmed output is ~30-50% of original size, signatures intact
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Task 5 — Writers
|
|
85
|
+
|
|
86
|
+
**Goal:** Write all `.nogrep/` files from structured input.
|
|
87
|
+
|
|
88
|
+
- [x] Create `scripts/write.ts`
|
|
89
|
+
- Accepts JSON via stdin or `--input <file>` with NodeResult[] + StackResult
|
|
90
|
+
- `writeContextNodes(nodes, outputDir)` — generates markdown with frontmatter (YAML)
|
|
91
|
+
- Creates subdirectories: `domains/`, `architecture/`, `flows/`, `entities/`
|
|
92
|
+
- Appends empty `## Manual Notes` section at end
|
|
93
|
+
- Existing files: extract Manual Notes, regenerate, re-inject Manual Notes
|
|
94
|
+
- `buildIndex(nodes, stack)` → writes `_index.json`
|
|
95
|
+
- Builds reverse maps: tags → [files], keywords → [files], paths → entry
|
|
96
|
+
- Populates `inverse_relations` by scanning all `relates_to` across nodes
|
|
97
|
+
- `buildRegistry(nodes)` → writes `_registry.json`
|
|
98
|
+
- `patchClaudeMd(projectRoot)` — appends navigation instructions
|
|
99
|
+
- Checks for `<!-- nogrep -->` marker to avoid duplicate patching
|
|
100
|
+
- [x] Create `templates/claude-md-patch.md`:
|
|
101
|
+
```markdown
|
|
102
|
+
<!-- nogrep -->
|
|
103
|
+
## Code Navigation
|
|
104
|
+
|
|
105
|
+
This project uses [nogrep](https://github.com/techtulp/nogrep).
|
|
106
|
+
Context files in `.nogrep/` are a navigable index of this codebase.
|
|
107
|
+
When you see nogrep results injected into your context, trust them —
|
|
108
|
+
read those files before exploring source.
|
|
109
|
+
<!-- /nogrep -->
|
|
110
|
+
```
|
|
111
|
+
- [x] Write `tests/writer.test.ts` — write to temp dirs, verify file contents and frontmatter
|
|
112
|
+
- [x] Verify: running writers on fixture data produces valid markdown with parseable frontmatter
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Task 6 — Init Slash Command
|
|
117
|
+
|
|
118
|
+
**Goal:** `/nogrep:init` orchestrates the full pipeline with Claude doing the AI work.
|
|
119
|
+
|
|
120
|
+
- [x] Create `commands/init.md`
|
|
121
|
+
- Step 1: Run `node "${CLAUDE_PLUGIN_ROOT}/dist/signals.js" --root .` → collect signals
|
|
122
|
+
- Step 2: Embed Phase 2 prompt — Claude analyzes signals, produces StackResult JSON
|
|
123
|
+
- Step 3: For each domain cluster, embed Phase 3 prompt — Claude reads trimmed source (via `node "${CLAUDE_PLUGIN_ROOT}/dist/trim.js"`), produces NodeResult JSON
|
|
124
|
+
- Step 4: Claude detects flows (clusters touching 3+ domains or named with flow keywords)
|
|
125
|
+
- Step 5: Run `node "${CLAUDE_PLUGIN_ROOT}/dist/write.js"` with all results piped as JSON stdin
|
|
126
|
+
- Step 6: Run `node "${CLAUDE_PLUGIN_ROOT}/dist/settings.js" --set enabled=true`
|
|
127
|
+
- See docs/SPEC.md Section 13 for prompt templates
|
|
128
|
+
- [x] Test: run `/nogrep:init` in CC on a fixture project, inspect `.nogrep/` output
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Task 7 — Query System
|
|
133
|
+
|
|
134
|
+
**Goal:** Fast index lookup without AI.
|
|
135
|
+
|
|
136
|
+
- [x] Create `scripts/query.ts`
|
|
137
|
+
- `extractTerms(question, taxonomy)` → `{ tags, keywords }`
|
|
138
|
+
- Split question into words, lowercase
|
|
139
|
+
- Match against taxonomy domain/tech values → tags
|
|
140
|
+
- Match against any word → keywords (pass through)
|
|
141
|
+
- No AI — pure string matching
|
|
142
|
+
- `resolve(terms, index)` → `RankedResult[]`
|
|
143
|
+
- Union lookup: find all nodes matching any tag or keyword
|
|
144
|
+
- Score: +2 per tag match, +1 per keyword match
|
|
145
|
+
- Sort by score descending, return top N (default 5)
|
|
146
|
+
- CLI interface: `node query.js --tags <tags> | --keywords <words> | --question <text> [--format paths|json|summary] [--limit N]`
|
|
147
|
+
- Throws `NogrepError('NO_INDEX')` if `_index.json` missing
|
|
148
|
+
- [x] Create `commands/query.md` slash command — runs `node "${CLAUDE_PLUGIN_ROOT}/dist/query.js" --question "$ARGUMENTS"`
|
|
149
|
+
- [x] Write `tests/query.test.ts` — test extraction and resolution
|
|
150
|
+
- [ ] Verify: `node dist/query.js --question "how does stripe work"` returns billing context file
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Task 8 — Validator + Update + Status
|
|
155
|
+
|
|
156
|
+
**Goal:** Staleness detection and incremental updates.
|
|
157
|
+
|
|
158
|
+
- [x] Create `scripts/validate.ts`
|
|
159
|
+
- `checkFreshness(node, projectRoot)` → `StaleResult`
|
|
160
|
+
- Glob all files matching node's `src_paths`
|
|
161
|
+
- Compute SHA256 of all file contents concatenated
|
|
162
|
+
- Compare to `last_synced.src_hash` in frontmatter
|
|
163
|
+
- CLI interface: `node validate.js [--format text|json]` → staleness report
|
|
164
|
+
- [x] Create `commands/update.md` slash command
|
|
165
|
+
- Guides Claude through: git diff → map to affected nodes → re-analyze → write updates
|
|
166
|
+
- Preserves `## Manual Notes` section
|
|
167
|
+
- [x] Create `commands/status.md` slash command
|
|
168
|
+
- Runs `node "${CLAUDE_PLUGIN_ROOT}/dist/validate.js"` and shows node counts, freshness summary
|
|
169
|
+
- [x] Write `tests/validate.test.ts` — test staleness detection
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Task 9 — Hooks
|
|
174
|
+
|
|
175
|
+
**Goal:** Automatic context injection via CC hooks.
|
|
176
|
+
|
|
177
|
+
- [x] Create `hooks/pre-tool-use.sh` (see docs/SPEC.md Section 10)
|
|
178
|
+
- Intercepts grep/find/rg/ag commands
|
|
179
|
+
- Calls `node "${CLAUDE_PLUGIN_ROOT}/dist/query.js"` with extracted keywords
|
|
180
|
+
- Injects results as `additionalContext`
|
|
181
|
+
- [x] Create `hooks/session-start.sh` (see docs/SPEC.md Section 10)
|
|
182
|
+
- Checks index existence and freshness on session start
|
|
183
|
+
- Calls `node "${CLAUDE_PLUGIN_ROOT}/dist/validate.js"`
|
|
184
|
+
- [x] Create `hooks/prompt-submit.sh` (see docs/SPEC.md Section 10)
|
|
185
|
+
- Injects relevant context for code navigation prompts
|
|
186
|
+
- Calls `node "${CLAUDE_PLUGIN_ROOT}/dist/query.js"`
|
|
187
|
+
- [x] Make all `.sh` scripts executable (`chmod +x`)
|
|
188
|
+
- [ ] Test: install plugin locally in CC, verify hooks fire
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Task 10 — README + Distribution
|
|
193
|
+
|
|
194
|
+
**Goal:** Ready for npm publish as CC plugin.
|
|
195
|
+
|
|
196
|
+
- [x] Write `README.md`:
|
|
197
|
+
- What it does (one paragraph)
|
|
198
|
+
- Install as CC plugin
|
|
199
|
+
- Quick start (3 steps)
|
|
200
|
+
- How it works (brief pipeline overview)
|
|
201
|
+
- Available commands
|
|
202
|
+
- FAQ
|
|
203
|
+
- [x] Add `files` field to `package.json` — ship `dist/`, `commands/`, `hooks/`, `templates/`, `plugin.json`
|
|
204
|
+
- [x] Verify `npm pack` produces correct bundle
|
|
205
|
+
- [x] Add `prepublish` script: `npm run build && npm test`
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Definition of Done
|
|
210
|
+
|
|
211
|
+
All tasks complete when:
|
|
212
|
+
- `/nogrep:init` runs successfully in CC on a real project and produces valid `.nogrep/`
|
|
213
|
+
- `/nogrep:query` returns correct context files
|
|
214
|
+
- CC hooks intercept grep commands and inject nogrep context
|
|
215
|
+
- All unit tests pass: `npm test`
|
|
216
|
+
- README is clear enough for a stranger to get started in 2 minutes
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionStart": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"UserPromptSubmit": [
|
|
25
|
+
{
|
|
26
|
+
"hooks": [
|
|
27
|
+
{
|
|
28
|
+
"type": "command",
|
|
29
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/prompt-submit.sh"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
INPUT=$(cat)
|
|
3
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
|
4
|
+
|
|
5
|
+
# Only intercept search commands
|
|
6
|
+
if ! echo "$COMMAND" | grep -qE '^\s*(grep|find|rg|ag|fd)\s'; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# Check nogrep is enabled
|
|
11
|
+
ENABLED=$(cat .claude/settings.json 2>/dev/null | jq -r '.nogrep.enabled // false')
|
|
12
|
+
LOCAL_ENABLED=$(cat .claude/settings.local.json 2>/dev/null | jq -r '.nogrep.enabled // empty')
|
|
13
|
+
[ -n "$LOCAL_ENABLED" ] && ENABLED="$LOCAL_ENABLED"
|
|
14
|
+
[ "$ENABLED" != "true" ] && exit 0
|
|
15
|
+
|
|
16
|
+
# Check index exists
|
|
17
|
+
[ ! -f ".nogrep/_index.json" ] && exit 0
|
|
18
|
+
|
|
19
|
+
# Extract keywords from the grep command
|
|
20
|
+
KEYWORDS=$(echo "$COMMAND" \
|
|
21
|
+
| sed -E 's/(grep|rg|ag|find)\s+(-[a-zA-Z]+\s+)*//' \
|
|
22
|
+
| tr -d '"'"'" \
|
|
23
|
+
| awk '{print $1}')
|
|
24
|
+
|
|
25
|
+
[ -z "$KEYWORDS" ] && exit 0
|
|
26
|
+
|
|
27
|
+
# Query nogrep
|
|
28
|
+
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT}/dist"
|
|
29
|
+
RESULT=$(node "$SCRIPT_DIR/query.js" --keywords "$KEYWORDS" --format summary --limit 3 2>/dev/null)
|
|
30
|
+
|
|
31
|
+
if [ -n "$RESULT" ]; then
|
|
32
|
+
jq -n \
|
|
33
|
+
--arg ctx "nogrep — read these context files before searching:\n\n$RESULT\n\nThese files tell you exactly where to look. Only proceed with the grep if they don't answer your question." \
|
|
34
|
+
'{ additionalContext: $ctx }'
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
exit 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
INPUT=$(cat)
|
|
3
|
+
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')
|
|
4
|
+
|
|
5
|
+
ENABLED=$(cat .claude/settings.json 2>/dev/null | jq -r '.nogrep.enabled // false')
|
|
6
|
+
LOCAL_ENABLED=$(cat .claude/settings.local.json 2>/dev/null | jq -r '.nogrep.enabled // empty')
|
|
7
|
+
[ -n "$LOCAL_ENABLED" ] && ENABLED="$LOCAL_ENABLED"
|
|
8
|
+
[ "$ENABLED" != "true" ] && exit 0
|
|
9
|
+
[ ! -f ".nogrep/_index.json" ] && exit 0
|
|
10
|
+
[ -z "$PROMPT" ] && exit 0
|
|
11
|
+
|
|
12
|
+
# Only inject for prompts that seem to be about code navigation
|
|
13
|
+
if ! echo "$PROMPT" | grep -qiE '(where|how|which|what|find|look|show|implement|fix|add|change|update|refactor)'; then
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT}/dist"
|
|
18
|
+
RESULT=$(node "$SCRIPT_DIR/query.js" --question "$PROMPT" --format summary --limit 3 2>/dev/null)
|
|
19
|
+
|
|
20
|
+
if [ -n "$RESULT" ]; then
|
|
21
|
+
jq -n \
|
|
22
|
+
--arg ctx "nogrep context for your question:\n\n$RESULT\n\nRead these files first before exploring source." \
|
|
23
|
+
'{ additionalContext: $ctx }'
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
exit 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
ENABLED=$(cat .claude/settings.json 2>/dev/null | jq -r '.nogrep.enabled // false')
|
|
3
|
+
LOCAL_ENABLED=$(cat .claude/settings.local.json 2>/dev/null | jq -r '.nogrep.enabled // empty')
|
|
4
|
+
[ -n "$LOCAL_ENABLED" ] && ENABLED="$LOCAL_ENABLED"
|
|
5
|
+
[ "$ENABLED" != "true" ] && exit 0
|
|
6
|
+
|
|
7
|
+
if [ ! -f ".nogrep/_index.json" ]; then
|
|
8
|
+
jq -n '{ additionalContext: "nogrep is enabled but no index found. Run `/nogrep:init` to generate the codebase index before starting work." }'
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT}/dist"
|
|
13
|
+
STALE=$(node "$SCRIPT_DIR/validate.js" --format json 2>/dev/null | jq -r '.stale[]?.file' | head -3)
|
|
14
|
+
|
|
15
|
+
if [ -n "$STALE" ]; then
|
|
16
|
+
jq -n \
|
|
17
|
+
--arg s "$STALE" \
|
|
18
|
+
'{ additionalContext: ("nogrep index may be stale. Consider running `/nogrep:update` before starting.\nStale nodes:\n" + $s) }'
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exit 0
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nogrep",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Navigable codebase index for Claude Code — stop grepping, start navigating",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": ["dist", "commands", "hooks", "scripts", "templates", "docs"],
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup",
|
|
9
|
+
"prepublishOnly": "npm run build",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"glob": "^11.0.0",
|
|
14
|
+
"gray-matter": "^4.0.3",
|
|
15
|
+
"js-yaml": "^4.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/js-yaml": "^4.0.9",
|
|
19
|
+
"@types/node": "^20.0.0",
|
|
20
|
+
"tsup": "^8.0.0",
|
|
21
|
+
"typescript": "^5.4.0",
|
|
22
|
+
"vitest": "^2.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/scripts/query.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve as resolvePath } from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import type { IndexJson, RankedResult, Taxonomy } from './types.js'
|
|
5
|
+
import { NogrepError } from './types.js'
|
|
6
|
+
|
|
7
|
+
// --- Term extraction ---
|
|
8
|
+
|
|
9
|
+
export function extractTerms(
|
|
10
|
+
question: string,
|
|
11
|
+
taxonomy: Taxonomy,
|
|
12
|
+
): { tags: string[]; keywords: string[] } {
|
|
13
|
+
const words = question
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^\w\s-]/g, ' ')
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter(w => w.length > 1)
|
|
18
|
+
|
|
19
|
+
const tags: string[] = []
|
|
20
|
+
const keywords: string[] = []
|
|
21
|
+
|
|
22
|
+
// Collect all taxonomy values for matching
|
|
23
|
+
const tagLookup = new Map<string, string>()
|
|
24
|
+
|
|
25
|
+
for (const val of taxonomy.static.layer) {
|
|
26
|
+
tagLookup.set(val.toLowerCase(), `layer:${val}`)
|
|
27
|
+
}
|
|
28
|
+
for (const val of taxonomy.static.concern) {
|
|
29
|
+
tagLookup.set(val.toLowerCase(), `concern:${val}`)
|
|
30
|
+
}
|
|
31
|
+
for (const val of taxonomy.static.type) {
|
|
32
|
+
tagLookup.set(val.toLowerCase(), `type:${val}`)
|
|
33
|
+
}
|
|
34
|
+
for (const val of taxonomy.dynamic.domain) {
|
|
35
|
+
tagLookup.set(val.toLowerCase(), `domain:${val}`)
|
|
36
|
+
}
|
|
37
|
+
for (const val of taxonomy.dynamic.tech) {
|
|
38
|
+
tagLookup.set(val.toLowerCase(), `tech:${val}`)
|
|
39
|
+
}
|
|
40
|
+
for (const [cat, values] of Object.entries(taxonomy.custom)) {
|
|
41
|
+
for (const val of values) {
|
|
42
|
+
tagLookup.set(val.toLowerCase(), `${cat}:${val}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Stop words to skip as keywords
|
|
47
|
+
const stopWords = new Set([
|
|
48
|
+
'the', 'is', 'at', 'in', 'of', 'on', 'to', 'a', 'an', 'and', 'or',
|
|
49
|
+
'for', 'it', 'do', 'does', 'how', 'what', 'where', 'which', 'when',
|
|
50
|
+
'who', 'why', 'this', 'that', 'with', 'from', 'by', 'be', 'as',
|
|
51
|
+
'are', 'was', 'were', 'been', 'has', 'have', 'had', 'not', 'but',
|
|
52
|
+
'if', 'my', 'our', 'its', 'can', 'will', 'should', 'would', 'could',
|
|
53
|
+
'about', 'after', 'work', 'works', 'use', 'uses', 'used',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
for (const word of words) {
|
|
57
|
+
const tag = tagLookup.get(word)
|
|
58
|
+
if (tag && !tags.includes(tag)) {
|
|
59
|
+
tags.push(tag)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Also check hyphenated compound matches (e.g. "error-handling")
|
|
63
|
+
if (!tag && !stopWords.has(word)) {
|
|
64
|
+
keywords.push(word)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for multi-word tag matches (e.g. "error handling" → "error-handling")
|
|
69
|
+
const questionLower = question.toLowerCase()
|
|
70
|
+
for (const [val, tag] of tagLookup.entries()) {
|
|
71
|
+
if (val.includes('-')) {
|
|
72
|
+
const spacedVersion = val.replace(/-/g, ' ')
|
|
73
|
+
if (questionLower.includes(spacedVersion) && !tags.includes(tag)) {
|
|
74
|
+
tags.push(tag)
|
|
75
|
+
}
|
|
76
|
+
if (questionLower.includes(val) && !tags.includes(tag)) {
|
|
77
|
+
tags.push(tag)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { tags, keywords }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Resolution ---
|
|
86
|
+
|
|
87
|
+
export function resolveQuery(
|
|
88
|
+
terms: { tags: string[]; keywords: string[] },
|
|
89
|
+
index: IndexJson,
|
|
90
|
+
limit = 5,
|
|
91
|
+
): RankedResult[] {
|
|
92
|
+
const scoreMap = new Map<string, { score: number; matchedOn: string[] }>()
|
|
93
|
+
|
|
94
|
+
function addMatch(contextFile: string, score: number, matchLabel: string): void {
|
|
95
|
+
const existing = scoreMap.get(contextFile)
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.score += score
|
|
98
|
+
existing.matchedOn.push(matchLabel)
|
|
99
|
+
} else {
|
|
100
|
+
scoreMap.set(contextFile, { score, matchedOn: [matchLabel] })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Tag matching: +2 per match
|
|
105
|
+
for (const tag of terms.tags) {
|
|
106
|
+
const files = index.tags[tag]
|
|
107
|
+
if (files) {
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
addMatch(file, 2, `tag:${tag}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Keyword matching: +1 per match
|
|
115
|
+
for (const kw of terms.keywords) {
|
|
116
|
+
const kwLower = kw.toLowerCase()
|
|
117
|
+
|
|
118
|
+
// Direct keyword lookup
|
|
119
|
+
const files = index.keywords[kwLower]
|
|
120
|
+
if (files) {
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
addMatch(file, 1, `keyword:${kwLower}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Also search all index keywords for partial matches
|
|
127
|
+
for (const [indexKw, kwFiles] of Object.entries(index.keywords)) {
|
|
128
|
+
if (indexKw === kwLower) continue // Already handled
|
|
129
|
+
if (indexKw.includes(kwLower) || kwLower.includes(indexKw)) {
|
|
130
|
+
for (const file of kwFiles) {
|
|
131
|
+
addMatch(file, 1, `keyword:${indexKw}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sort by score descending, then alphabetically for ties
|
|
138
|
+
const results: RankedResult[] = [...scoreMap.entries()]
|
|
139
|
+
.sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0]))
|
|
140
|
+
.slice(0, limit)
|
|
141
|
+
.map(([contextFile, { score, matchedOn }]) => ({
|
|
142
|
+
contextFile,
|
|
143
|
+
score,
|
|
144
|
+
matchedOn: [...new Set(matchedOn)],
|
|
145
|
+
summary: `Matched: ${[...new Set(matchedOn)].join(', ')}`,
|
|
146
|
+
}))
|
|
147
|
+
|
|
148
|
+
return results
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Index + taxonomy loading ---
|
|
152
|
+
|
|
153
|
+
async function loadIndex(projectRoot: string): Promise<IndexJson> {
|
|
154
|
+
const indexPath = join(projectRoot, '.nogrep', '_index.json')
|
|
155
|
+
try {
|
|
156
|
+
const content = await readFile(indexPath, 'utf-8')
|
|
157
|
+
return JSON.parse(content) as IndexJson
|
|
158
|
+
} catch {
|
|
159
|
+
throw new NogrepError(
|
|
160
|
+
'No .nogrep/_index.json found. Run /nogrep:init first.',
|
|
161
|
+
'NO_INDEX',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function loadTaxonomy(projectRoot: string): Promise<Taxonomy> {
|
|
167
|
+
const taxonomyPath = join(projectRoot, '.nogrep', '_taxonomy.json')
|
|
168
|
+
try {
|
|
169
|
+
const content = await readFile(taxonomyPath, 'utf-8')
|
|
170
|
+
return JSON.parse(content) as Taxonomy
|
|
171
|
+
} catch {
|
|
172
|
+
// Return default taxonomy if file doesn't exist
|
|
173
|
+
return {
|
|
174
|
+
static: {
|
|
175
|
+
layer: ['presentation', 'business', 'data', 'infrastructure', 'cross-cutting'],
|
|
176
|
+
concern: ['security', 'performance', 'caching', 'validation', 'error-handling', 'idempotency', 'observability'],
|
|
177
|
+
type: ['module', 'flow', 'entity', 'integration', 'config', 'ui', 'test'],
|
|
178
|
+
},
|
|
179
|
+
dynamic: { domain: [], tech: [] },
|
|
180
|
+
custom: {},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildTaxonomyFromIndex(index: IndexJson, baseTaxonomy: Taxonomy): Taxonomy {
|
|
186
|
+
// Extract dynamic domain and tech values from the index tags
|
|
187
|
+
const domains = new Set<string>(baseTaxonomy.dynamic.domain)
|
|
188
|
+
const techs = new Set<string>(baseTaxonomy.dynamic.tech)
|
|
189
|
+
|
|
190
|
+
for (const tagKey of Object.keys(index.tags)) {
|
|
191
|
+
const [category, value] = tagKey.split(':')
|
|
192
|
+
if (!category || !value) continue
|
|
193
|
+
if (category === 'domain') domains.add(value)
|
|
194
|
+
if (category === 'tech') techs.add(value)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...baseTaxonomy,
|
|
199
|
+
dynamic: {
|
|
200
|
+
domain: [...domains],
|
|
201
|
+
tech: [...techs],
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Formatting ---
|
|
207
|
+
|
|
208
|
+
function formatPaths(results: RankedResult[]): string {
|
|
209
|
+
return results.map(r => r.contextFile).join('\n')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatJson(results: RankedResult[]): string {
|
|
213
|
+
return JSON.stringify(results, null, 2)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function formatSummary(results: RankedResult[]): string {
|
|
217
|
+
if (results.length === 0) return 'No matching context files found.'
|
|
218
|
+
return results
|
|
219
|
+
.map(r => `- ${r.contextFile} (score: ${r.score}) — ${r.summary}`)
|
|
220
|
+
.join('\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- CLI ---
|
|
224
|
+
|
|
225
|
+
async function main(): Promise<void> {
|
|
226
|
+
const { values } = parseArgs({
|
|
227
|
+
options: {
|
|
228
|
+
tags: { type: 'string' },
|
|
229
|
+
keywords: { type: 'string' },
|
|
230
|
+
question: { type: 'string' },
|
|
231
|
+
format: { type: 'string', default: 'json' },
|
|
232
|
+
limit: { type: 'string', default: '5' },
|
|
233
|
+
root: { type: 'string', default: process.cwd() },
|
|
234
|
+
},
|
|
235
|
+
strict: true,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const root = resolvePath(values.root ?? process.cwd())
|
|
239
|
+
const limit = parseInt(values.limit ?? '5', 10)
|
|
240
|
+
const format = values.format ?? 'json'
|
|
241
|
+
|
|
242
|
+
const index = await loadIndex(root)
|
|
243
|
+
const baseTaxonomy = await loadTaxonomy(root)
|
|
244
|
+
const taxonomy = buildTaxonomyFromIndex(index, baseTaxonomy)
|
|
245
|
+
|
|
246
|
+
let terms: { tags: string[]; keywords: string[] }
|
|
247
|
+
|
|
248
|
+
if (values.question) {
|
|
249
|
+
terms = extractTerms(values.question, taxonomy)
|
|
250
|
+
} else if (values.tags || values.keywords) {
|
|
251
|
+
const tags = values.tags
|
|
252
|
+
? values.tags.split(',').map(t => t.trim()).filter(Boolean)
|
|
253
|
+
: []
|
|
254
|
+
const keywords = values.keywords
|
|
255
|
+
? values.keywords.split(',').map(k => k.trim()).filter(Boolean)
|
|
256
|
+
: []
|
|
257
|
+
terms = { tags, keywords }
|
|
258
|
+
} else {
|
|
259
|
+
process.stderr.write(
|
|
260
|
+
JSON.stringify({ error: 'Usage: node query.js --tags <tags> | --keywords <words> | --question <text> [--format paths|json|summary] [--limit N]' }) + '\n',
|
|
261
|
+
)
|
|
262
|
+
process.exitCode = 1
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const results = resolveQuery(terms, index, limit)
|
|
267
|
+
|
|
268
|
+
switch (format) {
|
|
269
|
+
case 'paths':
|
|
270
|
+
process.stdout.write(formatPaths(results) + '\n')
|
|
271
|
+
break
|
|
272
|
+
case 'summary':
|
|
273
|
+
process.stdout.write(formatSummary(results) + '\n')
|
|
274
|
+
break
|
|
275
|
+
case 'json':
|
|
276
|
+
default:
|
|
277
|
+
process.stdout.write(formatJson(results) + '\n')
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
main().catch((err: unknown) => {
|
|
283
|
+
if (err instanceof NogrepError) {
|
|
284
|
+
process.stderr.write(JSON.stringify({ error: err.message, code: err.code }) + '\n')
|
|
285
|
+
} else {
|
|
286
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
287
|
+
process.stderr.write(JSON.stringify({ error: message }) + '\n')
|
|
288
|
+
}
|
|
289
|
+
process.exitCode = 1
|
|
290
|
+
})
|