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
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
@@ -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
+ }
@@ -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
+ })