moru-narrow 26.45.0__tar.gz
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.
- moru_narrow-26.45.0/LICENSE +21 -0
- moru_narrow-26.45.0/PKG-INFO +200 -0
- moru_narrow-26.45.0/README.md +180 -0
- moru_narrow-26.45.0/moru_narrow/__init__.py +0 -0
- moru_narrow-26.45.0/moru_narrow/__main__.py +34 -0
- moru_narrow-26.45.0/moru_narrow/install.py +63 -0
- moru_narrow-26.45.0/moru_narrow/kotlin.py +145 -0
- moru_narrow-26.45.0/moru_narrow/typescript.py +181 -0
- moru_narrow-26.45.0/moru_narrow.egg-info/PKG-INFO +200 -0
- moru_narrow-26.45.0/moru_narrow.egg-info/SOURCES.txt +13 -0
- moru_narrow-26.45.0/moru_narrow.egg-info/dependency_links.txt +1 -0
- moru_narrow-26.45.0/moru_narrow.egg-info/entry_points.txt +3 -0
- moru_narrow-26.45.0/moru_narrow.egg-info/top_level.txt +1 -0
- moru_narrow-26.45.0/pyproject.toml +32 -0
- moru_narrow-26.45.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 taehwandev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moru-narrow
|
|
3
|
+
Version: 26.45.0
|
|
4
|
+
Summary: Narrow `rg -w <Name>` to the files and lines you must edit after a signature change (Kotlin+Gradle, TypeScript).
|
|
5
|
+
Author: taehwandev
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/taehwandev/moru-narrow
|
|
8
|
+
Project-URL: Issues, https://github.com/taehwandev/moru-narrow/issues
|
|
9
|
+
Keywords: ripgrep,rg,refactoring,coding-agent,kotlin,typescript,claude-code,codex
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# moru-narrow
|
|
22
|
+
|
|
23
|
+
**English** | [한국어](README.ko.md)
|
|
24
|
+
|
|
25
|
+
> **rg tells you every line that mentions a name. moru-narrow tells you which of those lines you must edit.** It keeps every hit; it only changes the order and what is shown first.
|
|
26
|
+
|
|
27
|
+
`moru-narrow` post-processes `rg -w <Name>` for coding agents and humans. It keeps every hit, ranks them, and prints the
|
|
28
|
+
files and **line ranges** to check first. No index, no database, no model calls, no daemon. Python standard library and
|
|
29
|
+
[ripgrep](https://github.com/BurntSushi/ripgrep) only.
|
|
30
|
+
|
|
31
|
+
> Scope: **signature changes** (add or change a parameter, then find what breaks). It is not a general `rg` replacement: for renaming a variable, tracing a logic bug or searching a config string, use plain `rg`.
|
|
32
|
+
>
|
|
33
|
+
> Status: alpha / experimental. MIT license. The examples use the `moru-narrow` command (alias `mnarrow`); from a clone, run `python3 -m moru_narrow` instead.
|
|
34
|
+
|
|
35
|
+
## In short
|
|
36
|
+
|
|
37
|
+
- **What it does:** after `rg -w name`, it lists the few files and line ranges you must edit, ranked, and hides the rest behind `--all`. Nothing is dropped.
|
|
38
|
+
- **Does it miss files?** Checked against the compiler: in all 217 Kotlin tasks every truth file was listed (rules were partly tuned on them), and 191 of 194 tasks (99.3% of files) on a fresh TypeScript set. The rare misses are files that never mention the name.
|
|
39
|
+
- **Does it help a coding agent?** In 6 test setups the compile success rate never dropped below the baseline. Context tokens fell by 9.8% to 34.5% in five setups; in the sixth (Codex on TypeScript) the change cannot be told apart from noise.
|
|
40
|
+
- **The catch:** the savings appeared only when the agent was also told not to re-run `rg`. Without that sentence, cost went up 10.3% [+0.0003, +0.0009 USD per task] in one test (Kotlin, Haiku, 57 tasks).
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
$ moru-narrow getGlobalTheme --repo ./my-next-app
|
|
44
|
+
getGlobalTheme declared: src/lib/theme-settings.ts:28
|
|
45
|
+
TIER 1: check these (3 files). rg found 4 files, 6 lines.
|
|
46
|
+
src/app/[locale]/(walled)/manage/(protected)/admin/settings/page.tsx
|
|
47
|
+
5: import { getGlobalTheme, saveGlobalTheme, type ThemeMode } from '@/lib/theme-settings';
|
|
48
|
+
19-22: getGlobalTheme({ fresh: true }).then(t => {
|
|
49
|
+
...
|
|
50
|
+
hidden: tier 2 has 0 files (use --all to list them)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Needs Python 3.9+ and [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) on your `PATH`. No other dependency. It does not need Moru Core or any other tool.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pipx install git+https://github.com/taehwandev/moru-narrow # or: uv tool install git+https://... / pip install git+https://...
|
|
59
|
+
moru-narrow <Name> --repo /path/to/your/project # Kotlin+Gradle or TypeScript, auto-detected
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Without installing, from a clone: `git clone https://github.com/taehwandev/moru-narrow && cd moru-narrow && python3 -m moru_narrow <Name> --repo ...`.
|
|
63
|
+
|
|
64
|
+
Not published on PyPI or npm yet. The `pip install git+...` route was tested in a clean virtualenv on macOS with Python 3.9.6; other platforms and Python versions are untested.
|
|
65
|
+
|
|
66
|
+
## Use with your coding agent
|
|
67
|
+
|
|
68
|
+
The tool only helps an agent when the agent is told how to use it (see the savings section: without the "do not re-run rg" sentence cost went up). One command registers that instruction:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
moru-narrow --install # agent skill in ~/.claude/skills/ (Claude Code) and ~/.agents/skills/ (Codex and other agent-skills tools)
|
|
72
|
+
moru-narrow --install --project # same, but into ./.claude and ./.agents so your team shares it via git
|
|
73
|
+
moru-narrow --install --agents-md # no skill support (e.g. Cursor): add a marked block to ./AGENTS.md
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Re-running is safe: an existing `SKILL.md` is kept (`--force` overwrites it), and the `AGENTS.md` block is replaced in place without touching your other text. `--target claude|agents` limits the skill install to one location. The installed text is based on the instruction used in the agent tests (worded differently, and extended as noted below):
|
|
77
|
+
|
|
78
|
+
> When a task changes a function or constructor signature (adds or changes a parameter), run `moru-narrow <Name> --repo <project root>` once and edit the call sites it lists under TIER 1. Do not run `rg` again for the same name. Use `--all` only if the list looks incomplete. For anything else (renames, logic bugs, config strings), or if moru-narrow reports an error or an unsupported language, use plain `rg`.
|
|
79
|
+
|
|
80
|
+
The exact wording was not the one measured, and the last sentence (use plain `rg` for other tasks and unsupported languages) is new: the tests only contained signature-change tasks and a prompt written for them. Any agent that can run a shell command can also call `moru-narrow` directly.
|
|
81
|
+
|
|
82
|
+
## Why
|
|
83
|
+
|
|
84
|
+
Changing a function signature means finding every place that breaks. `rg -w name` finds all of them, plus comments,
|
|
85
|
+
strings, same-named functions in other modules and unrelated files. On a Kotlin monorepo it returned **60 files for a
|
|
86
|
+
change that broke 3 to 4**. An agent then reads all of them.
|
|
87
|
+
|
|
88
|
+
A code-graph index was the obvious fix. We measured a syntax-level code-graph index on the same Kotlin repo and it did not
|
|
89
|
+
pay off at the agent level: no measurable cost difference with location-only output, and about +45.5% cost when it returned source code (that run also carried MCP connection overhead, so part of the increase is not the graph itself).
|
|
90
|
+
It also needs an index build and a sync after every edit (stale until synced). `moru-narrow` has no build step and no
|
|
91
|
+
staleness, because it runs `rg` live.
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
1. `rg -nw <Name>` collects hits (test files are excluded).
|
|
96
|
+
2. Each line gets a role: `use`, `import`, `override`, `declaration`, `comment`, `string`, `named-arg`, ...
|
|
97
|
+
3. A file is **tier 1** when it has a strong role (`use`/`import`/`override`) **and** can see the declaration:
|
|
98
|
+
- Kotlin: the module is reachable through Gradle `project()` edges, and the file is in the same package or imports it.
|
|
99
|
+
- TypeScript: the file imports the declaring file (relative paths and `tsconfig` aliases, through re-exporting
|
|
100
|
+
barrel files), or imports a wrapper that does and calls `X.name`, or is the declaring file itself.
|
|
101
|
+
4. Everything else is demoted to tier 2/3. **Filters only demote. `--all` always lists everything `rg` found.**
|
|
102
|
+
5. Multi-line calls are returned as ranges (`19-22`), because compilers report errors at argument lines.
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json] [--lang kotlin|ts]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Language is auto-detected (`tsconfig.json`, or a Gradle build).
|
|
109
|
+
|
|
110
|
+
## How to read the numbers
|
|
111
|
+
|
|
112
|
+
| Term | What it means here |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| **Truth files** | Production files that stop compiling after we add a required first parameter (`traceId`) to a function. The compiler decides, not us. |
|
|
115
|
+
| **Recall** | Whether the truth files appear in tier 1. The table counts tasks where all of them do (`23/23`); TypeScript rows also give files (`428/431` = 3 truth files were not listed). |
|
|
116
|
+
| **Tier 1** | The files moru-narrow tells you to read first. |
|
|
117
|
+
| **`rg -w` files / lines** | What plain ripgrep returns for the same name. This is the baseline to beat. |
|
|
118
|
+
| **Fresh set** | Candidates drawn after the rules were frozen. Nothing was tuned on them, so they are the fair test. |
|
|
119
|
+
| **Baseline vs. with moru-narrow** (agent tests) | Same coding agent, same task, same prompt. Baseline may use `rg`, `ls` and file reads. The other condition also gets the tool and the sentence "edit from the list, do not re-run rg for the same name". |
|
|
120
|
+
| **Compile pass** | The project compiles after the agent's edit (Kotlin: Gradle, TypeScript: `tsc --noEmit`). TypeScript runs also require that the declaration really gained the parameter, because an agent that changes nothing would otherwise pass. |
|
|
121
|
+
| **Context tokens** | Input tokens the model processed during the whole run, cached ones included. A proxy for how much it read. |
|
|
122
|
+
| **Cost** | API-equivalent price estimate reported by the Claude CLI, not an actual bill. Codex does not report it, shown as n/a. |
|
|
123
|
+
| **`[a, b]` interval** | 95% range from a task-paired bootstrap (10,000 resamples): tasks are resampled and the two conditions are compared on the same task. If the range includes 0, treat the difference as noise. |
|
|
124
|
+
| **tasks x reps** | Number of tasks times repetitions per task. Repetitions of one task are averaged before the comparison. |
|
|
125
|
+
|
|
126
|
+
## Does it miss files?
|
|
127
|
+
|
|
128
|
+
Ground truth is the **compiler**: add a required first parameter `traceId: String` / `traceId: string` to a function (or
|
|
129
|
+
Kotlin constructor), compile, and take the production files that fail as the truth.
|
|
130
|
+
|
|
131
|
+
| Set | Tasks | `rg -w` files / lines | moru-narrow tier 1 files / lines | Tasks with every truth file in tier 1 |
|
|
132
|
+
| --- | ---: | --- | --- | ---: |
|
|
133
|
+
| Kotlin, 23 selected noisy functions | 23 | 60.4 / 139 | 8.8 / 18 | 23/23 |
|
|
134
|
+
| Kotlin, held-out functions | 120 | 68.3 / 281 | 3.3 / 5 | 120/120 |
|
|
135
|
+
| Kotlin, held-out noisy subset (rg >= 20 files and >= 2x the truth) | 57 | 132.9 / 571 | 3.8 / 7 | 57/57 |
|
|
136
|
+
| Kotlin, constructor changes | 74 | 14.9 / 36 | 8.8 / 17 | 74/74 |
|
|
137
|
+
| TypeScript, **fresh** functions (v3 rules frozen first) | 194 | 3.5 / 7 | 2.5 / 6 | 191/194 (files: 428/431, 99.3%) |
|
|
138
|
+
| TypeScript, fresh, noisy functions (rg >= 5 files) | 28 | 9.5 / 20 | 8.2 / 18 | 28/28 (files: 193/193) |
|
|
139
|
+
|
|
140
|
+
Read these carefully:
|
|
141
|
+
|
|
142
|
+
- Kotlin rules were adjusted after the first held-out misses (two regex bugs), so the Kotlin sets are **not fully
|
|
143
|
+
independent**. The TypeScript fresh set is: rules were frozen (sha256 recorded) before it was scored.
|
|
144
|
+
- TypeScript misses that remain are cases where the failing file **never mentions the name** (the type flows through
|
|
145
|
+
another function). No `rg`-based tool can find those.
|
|
146
|
+
- Line level: error lines inside the recommended ranges were 90% (Kotlin functions), 96% within +-8 lines (Kotlin
|
|
147
|
+
constructors) and 90% (TypeScript functions). Use ranges as hints, files as the guarantee.
|
|
148
|
+
- The TypeScript compiler truth is built with the TypeScript API in memory against one private Next.js repo; Kotlin on
|
|
149
|
+
one private Compose Multiplatform repo. Neither repo is published here.
|
|
150
|
+
|
|
151
|
+
## Does it save an agent anything?
|
|
152
|
+
|
|
153
|
+
Same task, same prompt template, two conditions, task-paired bootstrap (10,000 resamples, 95% interval). Local logins
|
|
154
|
+
only. Cost is the API-equivalent estimate reported by the CLI.
|
|
155
|
+
|
|
156
|
+
| Setup | Compile pass (baseline -> with moru-narrow) | Context tokens | Cost |
|
|
157
|
+
| --- | --- | --- | --- |
|
|
158
|
+
| Kotlin, Haiku, 23 tasks x 2 | 46/46 -> 46/46 | **-34.5%** [-107k, -37k] | **-24.2%** |
|
|
159
|
+
| Kotlin, Haiku, 57 tasks x 2 | 112/114 -> 114/114 | **-19.2%** [-36k, -18k] | **-16.4%** |
|
|
160
|
+
| Kotlin, Codex terra, 23 x 2 | 44/46 -> 45/46 | **-14.2%** [-22.8%, -6.2%] | n/a |
|
|
161
|
+
| Kotlin, Codex terra, 57 x 2 | 112/114 -> 114/114 | **-9.8%** [-14.6%, -5.3%] | n/a |
|
|
162
|
+
| TypeScript, Haiku, 28 x 2 | 56/56 -> 56/56 | **-22.2%** [-29.4%, -15.3%] | **-11.1%** [-17.3%, -4.3%] |
|
|
163
|
+
| TypeScript, Codex terra, 28 x 2 | 56/56 -> 56/56 | -6.5% [-13.5%, 0.0%] (touches 0) | n/a |
|
|
164
|
+
|
|
165
|
+
**The agent instruction matters as much as the tool.** In the first Kotlin experiment the agent got the same list but
|
|
166
|
+
kept re-running `rg` afterwards, and cost went **up** 10.3% (context +12.8%) on the larger Haiku set. Adding one
|
|
167
|
+
sentence, "edit the call sites from this list without re-running rg for the same name; use --all only if the list looks
|
|
168
|
+
incomplete", changed that: with it, `rg` calls per run fell from 2.4 to 0.02 (Kotlin, Codex, 23 tasks) and from 2.8 to 0.5
|
|
169
|
+
(TypeScript, Haiku), and it produced the table above. That revised prompt was written after seeing the first
|
|
170
|
+
results, so treat the savings as exploratory, and ship the instruction together with the tool.
|
|
171
|
+
|
|
172
|
+
**What we learned the hard way.** The first TypeScript agent run failed 10 more tasks than the baseline (33/44 vs 43/44).
|
|
173
|
+
Every file the compiler flagged was in the list; the miss was calls *inside the declaring file*, which the first
|
|
174
|
+
version hid because a file does not import itself. The offline truth had also excluded the declaring file, so the offline
|
|
175
|
+
check could not see it. Fix: v3 keeps those lines in tier 1. Lesson: validate with the real agent loop, not only offline.
|
|
176
|
+
|
|
177
|
+
## Limits
|
|
178
|
+
|
|
179
|
+
- Languages: Kotlin + Gradle and TypeScript. Each language needs a small profile (role patterns, how to see a
|
|
180
|
+
declaration). Dynamic languages need a different ground truth.
|
|
181
|
+
- Verified on **one change shape** (adding a required first parameter) and **one repository per language**.
|
|
182
|
+
- React components are out of scope (a positional first parameter does not exist in JSX).
|
|
183
|
+
- Savings depend on the model. Codex on the TypeScript repo showed no clear reduction (-6.5%, interval touches 0). There
|
|
184
|
+
`rg` was already precise (3.5 files on average for functions) and the agent ran only ~3 commands per task; we did not test why.
|
|
185
|
+
- Sample sizes are small (28 to 57 tasks, 2 repetitions per task). In the baseline every run passed in TypeScript, so pass rate cannot show an improvement there, only that nothing broke.
|
|
186
|
+
- The agent tests sent private source to the model providers (Anthropic, OpenAI) through the author's own local CLI logins.
|
|
187
|
+
|
|
188
|
+
## Reproduce
|
|
189
|
+
|
|
190
|
+
The evaluation harnesses (agent runners, compiler-truth generators, scorers) are not in this repository yet: they carry local paths and private repository details and need a scrub first. `results/` holds the aggregate tables. The method is described above: compiler errors after adding a required first parameter are the ground truth; rules were frozen (hash recorded) before the fresh TypeScript set was scored.
|
|
191
|
+
|
|
192
|
+
## Versioning
|
|
193
|
+
|
|
194
|
+
Weekly CalVer `YY.WW.N` (scheme `calver-weekly`), for example `26.45.0`: two-digit year, week number, and a release count that starts at `0` each week and counts published packages. Weeks follow the Korean calendar (Asia/Seoul, Monday-start weeks). Git tags use a `v` prefix (`v26.45.0`); the package metadata omits it. Python normalises a zero-padded week, so week 5 appears as `26.5.0` on PyPI. This is a date-based version, not a compatibility signal: while the tool is alpha, read the commit log before upgrading.
|
|
195
|
+
|
|
196
|
+
## Status
|
|
197
|
+
|
|
198
|
+
Alpha. Not on PyPI or npm. No unit tests yet. The ranking was validated against compiler ground truth (see above); `--install` was only checked by hand in a temporary home directory (fresh install, re-run, `--project`, `--agents-md` idempotence). Issues and results from other repositories are welcome.
|
|
199
|
+
|
|
200
|
+
License: [MIT](LICENSE).
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# moru-narrow
|
|
2
|
+
|
|
3
|
+
**English** | [한국어](README.ko.md)
|
|
4
|
+
|
|
5
|
+
> **rg tells you every line that mentions a name. moru-narrow tells you which of those lines you must edit.** It keeps every hit; it only changes the order and what is shown first.
|
|
6
|
+
|
|
7
|
+
`moru-narrow` post-processes `rg -w <Name>` for coding agents and humans. It keeps every hit, ranks them, and prints the
|
|
8
|
+
files and **line ranges** to check first. No index, no database, no model calls, no daemon. Python standard library and
|
|
9
|
+
[ripgrep](https://github.com/BurntSushi/ripgrep) only.
|
|
10
|
+
|
|
11
|
+
> Scope: **signature changes** (add or change a parameter, then find what breaks). It is not a general `rg` replacement: for renaming a variable, tracing a logic bug or searching a config string, use plain `rg`.
|
|
12
|
+
>
|
|
13
|
+
> Status: alpha / experimental. MIT license. The examples use the `moru-narrow` command (alias `mnarrow`); from a clone, run `python3 -m moru_narrow` instead.
|
|
14
|
+
|
|
15
|
+
## In short
|
|
16
|
+
|
|
17
|
+
- **What it does:** after `rg -w name`, it lists the few files and line ranges you must edit, ranked, and hides the rest behind `--all`. Nothing is dropped.
|
|
18
|
+
- **Does it miss files?** Checked against the compiler: in all 217 Kotlin tasks every truth file was listed (rules were partly tuned on them), and 191 of 194 tasks (99.3% of files) on a fresh TypeScript set. The rare misses are files that never mention the name.
|
|
19
|
+
- **Does it help a coding agent?** In 6 test setups the compile success rate never dropped below the baseline. Context tokens fell by 9.8% to 34.5% in five setups; in the sixth (Codex on TypeScript) the change cannot be told apart from noise.
|
|
20
|
+
- **The catch:** the savings appeared only when the agent was also told not to re-run `rg`. Without that sentence, cost went up 10.3% [+0.0003, +0.0009 USD per task] in one test (Kotlin, Haiku, 57 tasks).
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
$ moru-narrow getGlobalTheme --repo ./my-next-app
|
|
24
|
+
getGlobalTheme declared: src/lib/theme-settings.ts:28
|
|
25
|
+
TIER 1: check these (3 files). rg found 4 files, 6 lines.
|
|
26
|
+
src/app/[locale]/(walled)/manage/(protected)/admin/settings/page.tsx
|
|
27
|
+
5: import { getGlobalTheme, saveGlobalTheme, type ThemeMode } from '@/lib/theme-settings';
|
|
28
|
+
19-22: getGlobalTheme({ fresh: true }).then(t => {
|
|
29
|
+
...
|
|
30
|
+
hidden: tier 2 has 0 files (use --all to list them)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
Needs Python 3.9+ and [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) on your `PATH`. No other dependency. It does not need Moru Core or any other tool.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install git+https://github.com/taehwandev/moru-narrow # or: uv tool install git+https://... / pip install git+https://...
|
|
39
|
+
moru-narrow <Name> --repo /path/to/your/project # Kotlin+Gradle or TypeScript, auto-detected
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without installing, from a clone: `git clone https://github.com/taehwandev/moru-narrow && cd moru-narrow && python3 -m moru_narrow <Name> --repo ...`.
|
|
43
|
+
|
|
44
|
+
Not published on PyPI or npm yet. The `pip install git+...` route was tested in a clean virtualenv on macOS with Python 3.9.6; other platforms and Python versions are untested.
|
|
45
|
+
|
|
46
|
+
## Use with your coding agent
|
|
47
|
+
|
|
48
|
+
The tool only helps an agent when the agent is told how to use it (see the savings section: without the "do not re-run rg" sentence cost went up). One command registers that instruction:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
moru-narrow --install # agent skill in ~/.claude/skills/ (Claude Code) and ~/.agents/skills/ (Codex and other agent-skills tools)
|
|
52
|
+
moru-narrow --install --project # same, but into ./.claude and ./.agents so your team shares it via git
|
|
53
|
+
moru-narrow --install --agents-md # no skill support (e.g. Cursor): add a marked block to ./AGENTS.md
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Re-running is safe: an existing `SKILL.md` is kept (`--force` overwrites it), and the `AGENTS.md` block is replaced in place without touching your other text. `--target claude|agents` limits the skill install to one location. The installed text is based on the instruction used in the agent tests (worded differently, and extended as noted below):
|
|
57
|
+
|
|
58
|
+
> When a task changes a function or constructor signature (adds or changes a parameter), run `moru-narrow <Name> --repo <project root>` once and edit the call sites it lists under TIER 1. Do not run `rg` again for the same name. Use `--all` only if the list looks incomplete. For anything else (renames, logic bugs, config strings), or if moru-narrow reports an error or an unsupported language, use plain `rg`.
|
|
59
|
+
|
|
60
|
+
The exact wording was not the one measured, and the last sentence (use plain `rg` for other tasks and unsupported languages) is new: the tests only contained signature-change tasks and a prompt written for them. Any agent that can run a shell command can also call `moru-narrow` directly.
|
|
61
|
+
|
|
62
|
+
## Why
|
|
63
|
+
|
|
64
|
+
Changing a function signature means finding every place that breaks. `rg -w name` finds all of them, plus comments,
|
|
65
|
+
strings, same-named functions in other modules and unrelated files. On a Kotlin monorepo it returned **60 files for a
|
|
66
|
+
change that broke 3 to 4**. An agent then reads all of them.
|
|
67
|
+
|
|
68
|
+
A code-graph index was the obvious fix. We measured a syntax-level code-graph index on the same Kotlin repo and it did not
|
|
69
|
+
pay off at the agent level: no measurable cost difference with location-only output, and about +45.5% cost when it returned source code (that run also carried MCP connection overhead, so part of the increase is not the graph itself).
|
|
70
|
+
It also needs an index build and a sync after every edit (stale until synced). `moru-narrow` has no build step and no
|
|
71
|
+
staleness, because it runs `rg` live.
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
1. `rg -nw <Name>` collects hits (test files are excluded).
|
|
76
|
+
2. Each line gets a role: `use`, `import`, `override`, `declaration`, `comment`, `string`, `named-arg`, ...
|
|
77
|
+
3. A file is **tier 1** when it has a strong role (`use`/`import`/`override`) **and** can see the declaration:
|
|
78
|
+
- Kotlin: the module is reachable through Gradle `project()` edges, and the file is in the same package or imports it.
|
|
79
|
+
- TypeScript: the file imports the declaring file (relative paths and `tsconfig` aliases, through re-exporting
|
|
80
|
+
barrel files), or imports a wrapper that does and calls `X.name`, or is the declaring file itself.
|
|
81
|
+
4. Everything else is demoted to tier 2/3. **Filters only demote. `--all` always lists everything `rg` found.**
|
|
82
|
+
5. Multi-line calls are returned as ranges (`19-22`), because compilers report errors at argument lines.
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json] [--lang kotlin|ts]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Language is auto-detected (`tsconfig.json`, or a Gradle build).
|
|
89
|
+
|
|
90
|
+
## How to read the numbers
|
|
91
|
+
|
|
92
|
+
| Term | What it means here |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| **Truth files** | Production files that stop compiling after we add a required first parameter (`traceId`) to a function. The compiler decides, not us. |
|
|
95
|
+
| **Recall** | Whether the truth files appear in tier 1. The table counts tasks where all of them do (`23/23`); TypeScript rows also give files (`428/431` = 3 truth files were not listed). |
|
|
96
|
+
| **Tier 1** | The files moru-narrow tells you to read first. |
|
|
97
|
+
| **`rg -w` files / lines** | What plain ripgrep returns for the same name. This is the baseline to beat. |
|
|
98
|
+
| **Fresh set** | Candidates drawn after the rules were frozen. Nothing was tuned on them, so they are the fair test. |
|
|
99
|
+
| **Baseline vs. with moru-narrow** (agent tests) | Same coding agent, same task, same prompt. Baseline may use `rg`, `ls` and file reads. The other condition also gets the tool and the sentence "edit from the list, do not re-run rg for the same name". |
|
|
100
|
+
| **Compile pass** | The project compiles after the agent's edit (Kotlin: Gradle, TypeScript: `tsc --noEmit`). TypeScript runs also require that the declaration really gained the parameter, because an agent that changes nothing would otherwise pass. |
|
|
101
|
+
| **Context tokens** | Input tokens the model processed during the whole run, cached ones included. A proxy for how much it read. |
|
|
102
|
+
| **Cost** | API-equivalent price estimate reported by the Claude CLI, not an actual bill. Codex does not report it, shown as n/a. |
|
|
103
|
+
| **`[a, b]` interval** | 95% range from a task-paired bootstrap (10,000 resamples): tasks are resampled and the two conditions are compared on the same task. If the range includes 0, treat the difference as noise. |
|
|
104
|
+
| **tasks x reps** | Number of tasks times repetitions per task. Repetitions of one task are averaged before the comparison. |
|
|
105
|
+
|
|
106
|
+
## Does it miss files?
|
|
107
|
+
|
|
108
|
+
Ground truth is the **compiler**: add a required first parameter `traceId: String` / `traceId: string` to a function (or
|
|
109
|
+
Kotlin constructor), compile, and take the production files that fail as the truth.
|
|
110
|
+
|
|
111
|
+
| Set | Tasks | `rg -w` files / lines | moru-narrow tier 1 files / lines | Tasks with every truth file in tier 1 |
|
|
112
|
+
| --- | ---: | --- | --- | ---: |
|
|
113
|
+
| Kotlin, 23 selected noisy functions | 23 | 60.4 / 139 | 8.8 / 18 | 23/23 |
|
|
114
|
+
| Kotlin, held-out functions | 120 | 68.3 / 281 | 3.3 / 5 | 120/120 |
|
|
115
|
+
| Kotlin, held-out noisy subset (rg >= 20 files and >= 2x the truth) | 57 | 132.9 / 571 | 3.8 / 7 | 57/57 |
|
|
116
|
+
| Kotlin, constructor changes | 74 | 14.9 / 36 | 8.8 / 17 | 74/74 |
|
|
117
|
+
| TypeScript, **fresh** functions (v3 rules frozen first) | 194 | 3.5 / 7 | 2.5 / 6 | 191/194 (files: 428/431, 99.3%) |
|
|
118
|
+
| TypeScript, fresh, noisy functions (rg >= 5 files) | 28 | 9.5 / 20 | 8.2 / 18 | 28/28 (files: 193/193) |
|
|
119
|
+
|
|
120
|
+
Read these carefully:
|
|
121
|
+
|
|
122
|
+
- Kotlin rules were adjusted after the first held-out misses (two regex bugs), so the Kotlin sets are **not fully
|
|
123
|
+
independent**. The TypeScript fresh set is: rules were frozen (sha256 recorded) before it was scored.
|
|
124
|
+
- TypeScript misses that remain are cases where the failing file **never mentions the name** (the type flows through
|
|
125
|
+
another function). No `rg`-based tool can find those.
|
|
126
|
+
- Line level: error lines inside the recommended ranges were 90% (Kotlin functions), 96% within +-8 lines (Kotlin
|
|
127
|
+
constructors) and 90% (TypeScript functions). Use ranges as hints, files as the guarantee.
|
|
128
|
+
- The TypeScript compiler truth is built with the TypeScript API in memory against one private Next.js repo; Kotlin on
|
|
129
|
+
one private Compose Multiplatform repo. Neither repo is published here.
|
|
130
|
+
|
|
131
|
+
## Does it save an agent anything?
|
|
132
|
+
|
|
133
|
+
Same task, same prompt template, two conditions, task-paired bootstrap (10,000 resamples, 95% interval). Local logins
|
|
134
|
+
only. Cost is the API-equivalent estimate reported by the CLI.
|
|
135
|
+
|
|
136
|
+
| Setup | Compile pass (baseline -> with moru-narrow) | Context tokens | Cost |
|
|
137
|
+
| --- | --- | --- | --- |
|
|
138
|
+
| Kotlin, Haiku, 23 tasks x 2 | 46/46 -> 46/46 | **-34.5%** [-107k, -37k] | **-24.2%** |
|
|
139
|
+
| Kotlin, Haiku, 57 tasks x 2 | 112/114 -> 114/114 | **-19.2%** [-36k, -18k] | **-16.4%** |
|
|
140
|
+
| Kotlin, Codex terra, 23 x 2 | 44/46 -> 45/46 | **-14.2%** [-22.8%, -6.2%] | n/a |
|
|
141
|
+
| Kotlin, Codex terra, 57 x 2 | 112/114 -> 114/114 | **-9.8%** [-14.6%, -5.3%] | n/a |
|
|
142
|
+
| TypeScript, Haiku, 28 x 2 | 56/56 -> 56/56 | **-22.2%** [-29.4%, -15.3%] | **-11.1%** [-17.3%, -4.3%] |
|
|
143
|
+
| TypeScript, Codex terra, 28 x 2 | 56/56 -> 56/56 | -6.5% [-13.5%, 0.0%] (touches 0) | n/a |
|
|
144
|
+
|
|
145
|
+
**The agent instruction matters as much as the tool.** In the first Kotlin experiment the agent got the same list but
|
|
146
|
+
kept re-running `rg` afterwards, and cost went **up** 10.3% (context +12.8%) on the larger Haiku set. Adding one
|
|
147
|
+
sentence, "edit the call sites from this list without re-running rg for the same name; use --all only if the list looks
|
|
148
|
+
incomplete", changed that: with it, `rg` calls per run fell from 2.4 to 0.02 (Kotlin, Codex, 23 tasks) and from 2.8 to 0.5
|
|
149
|
+
(TypeScript, Haiku), and it produced the table above. That revised prompt was written after seeing the first
|
|
150
|
+
results, so treat the savings as exploratory, and ship the instruction together with the tool.
|
|
151
|
+
|
|
152
|
+
**What we learned the hard way.** The first TypeScript agent run failed 10 more tasks than the baseline (33/44 vs 43/44).
|
|
153
|
+
Every file the compiler flagged was in the list; the miss was calls *inside the declaring file*, which the first
|
|
154
|
+
version hid because a file does not import itself. The offline truth had also excluded the declaring file, so the offline
|
|
155
|
+
check could not see it. Fix: v3 keeps those lines in tier 1. Lesson: validate with the real agent loop, not only offline.
|
|
156
|
+
|
|
157
|
+
## Limits
|
|
158
|
+
|
|
159
|
+
- Languages: Kotlin + Gradle and TypeScript. Each language needs a small profile (role patterns, how to see a
|
|
160
|
+
declaration). Dynamic languages need a different ground truth.
|
|
161
|
+
- Verified on **one change shape** (adding a required first parameter) and **one repository per language**.
|
|
162
|
+
- React components are out of scope (a positional first parameter does not exist in JSX).
|
|
163
|
+
- Savings depend on the model. Codex on the TypeScript repo showed no clear reduction (-6.5%, interval touches 0). There
|
|
164
|
+
`rg` was already precise (3.5 files on average for functions) and the agent ran only ~3 commands per task; we did not test why.
|
|
165
|
+
- Sample sizes are small (28 to 57 tasks, 2 repetitions per task). In the baseline every run passed in TypeScript, so pass rate cannot show an improvement there, only that nothing broke.
|
|
166
|
+
- The agent tests sent private source to the model providers (Anthropic, OpenAI) through the author's own local CLI logins.
|
|
167
|
+
|
|
168
|
+
## Reproduce
|
|
169
|
+
|
|
170
|
+
The evaluation harnesses (agent runners, compiler-truth generators, scorers) are not in this repository yet: they carry local paths and private repository details and need a scrub first. `results/` holds the aggregate tables. The method is described above: compiler errors after adding a required first parameter are the ground truth; rules were frozen (hash recorded) before the fresh TypeScript set was scored.
|
|
171
|
+
|
|
172
|
+
## Versioning
|
|
173
|
+
|
|
174
|
+
Weekly CalVer `YY.WW.N` (scheme `calver-weekly`), for example `26.45.0`: two-digit year, week number, and a release count that starts at `0` each week and counts published packages. Weeks follow the Korean calendar (Asia/Seoul, Monday-start weeks). Git tags use a `v` prefix (`v26.45.0`); the package metadata omits it. Python normalises a zero-padded week, so week 5 appears as `26.5.0` on PyPI. This is a date-based version, not a compatibility signal: while the tool is alpha, read the commit log before upgrading.
|
|
175
|
+
|
|
176
|
+
## Status
|
|
177
|
+
|
|
178
|
+
Alpha. Not on PyPI or npm. No unit tests yet. The ranking was validated against compiler ground truth (see above); `--install` was only checked by hand in a temporary home directory (fresh install, re-run, `--project`, `--agents-md` idempotence). Issues and results from other repositories are welcome.
|
|
179
|
+
|
|
180
|
+
License: [MIT](LICENSE).
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""moru-narrow: recommend the files and lines to check after `rg -w <Name>` (Kotlin + Gradle, TypeScript).
|
|
3
|
+
|
|
4
|
+
Static, no model calls, no index, standard library + ripgrep only. Filters only demote; --all lists everything rg found.
|
|
5
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json] [--lang kotlin|ts]
|
|
6
|
+
moru-narrow --install [--target claude|agents|both] [--project] [--agents-md] [--force]
|
|
7
|
+
"""
|
|
8
|
+
import importlib, sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect(repo):
|
|
13
|
+
root = Path(repo)
|
|
14
|
+
if (root / "tsconfig.json").exists(): return "ts"
|
|
15
|
+
if any(any(root.glob(g)) for g in ("settings.gradle*", "build.gradle*", "*/build.gradle*")): return "kotlin"
|
|
16
|
+
if (root / "package.json").exists(): return "ts"
|
|
17
|
+
sys.exit("moru-narrow: cannot detect the language (looked for tsconfig.json or a Gradle build at the repo root); pass --lang kotlin|ts")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
argv = sys.argv[1:]; lang = None; repo = "."
|
|
22
|
+
if argv[:1] == ["--install"]:
|
|
23
|
+
from moru_narrow import install
|
|
24
|
+
return install.main(argv[1:])
|
|
25
|
+
if "--lang" in argv:
|
|
26
|
+
i = argv.index("--lang"); lang = argv[i + 1]; del argv[i:i + 2]
|
|
27
|
+
if "--repo" in argv: repo = argv[argv.index("--repo") + 1]
|
|
28
|
+
lang = lang or detect(repo)
|
|
29
|
+
mod = importlib.import_module({"kotlin": "moru_narrow.kotlin", "ts": "moru_narrow.typescript"}[lang])
|
|
30
|
+
sys.argv = [f"moru-narrow({lang})"] + argv
|
|
31
|
+
mod.main()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`moru-narrow --install`: register moru-narrow with AI coding agents (agent-skills `SKILL.md`, or an `AGENTS.md` block).
|
|
2
|
+
|
|
3
|
+
moru-narrow --install [--target claude|agents|both] [--project] [--agents-md] [--force]
|
|
4
|
+
"""
|
|
5
|
+
import argparse, re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
RULE = ("When a task changes a function or constructor signature (adds or changes a parameter), run "
|
|
9
|
+
"`moru-narrow <Name> --repo <project root>` once and edit the call sites it lists under TIER 1. "
|
|
10
|
+
"Do not run `rg` again for the same name. Use `--all` only if the list looks incomplete. "
|
|
11
|
+
"For anything else (renames, logic bugs, config strings), or if moru-narrow reports an error or an unsupported language, use plain `rg`.")
|
|
12
|
+
|
|
13
|
+
SKILL = f"""---
|
|
14
|
+
name: moru-narrow
|
|
15
|
+
description: After a Kotlin (Gradle) or TypeScript function/constructor signature change, list the few files and line ranges that break, instead of reading every `rg` hit.
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# moru-narrow
|
|
19
|
+
|
|
20
|
+
{RULE}
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
moru-narrow getGlobalTheme --repo . # TIER 1 = files to edit; tier 2/3 are hidden, --all shows them
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It post-processes `rg -nw`, so every hit is still reachable with `--all`. No index, no network, no model calls.
|
|
27
|
+
If `moru-narrow` is not on PATH, use `python3 -m moru_narrow`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
BEGIN, END = "<!-- moru-narrow:begin -->", "<!-- moru-narrow:end -->"
|
|
31
|
+
BLOCK = f"{BEGIN}\n## moru-narrow\n\n{RULE}\n{END}\n"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def skill_dirs(target, project):
|
|
35
|
+
base = Path(".") if project else Path.home()
|
|
36
|
+
return [base / d / "skills" / "moru-narrow" for d in ((".claude",) if target == "claude" else (".agents",) if target == "agents" else (".claude", ".agents"))]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_skill(d, force):
|
|
40
|
+
f = d / "SKILL.md"
|
|
41
|
+
if f.exists() and not force: return f"kept {f} (exists, use --force to overwrite)"
|
|
42
|
+
d.mkdir(parents=True, exist_ok=True); f.write_text(SKILL)
|
|
43
|
+
return f"wrote {f}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_agents_md(path):
|
|
47
|
+
text = path.read_text() if path.exists() else ""
|
|
48
|
+
pat = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", re.S)
|
|
49
|
+
new = pat.sub(lambda _: BLOCK, text) if pat.search(text) else (text + ("\n" if text and not text.endswith("\n\n") else "") + BLOCK)
|
|
50
|
+
path.write_text(new)
|
|
51
|
+
return f"updated {path}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv):
|
|
55
|
+
ap = argparse.ArgumentParser(prog="moru-narrow --install", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
56
|
+
ap.add_argument("--target", choices=("claude", "agents", "both"), default="both")
|
|
57
|
+
ap.add_argument("--project", action="store_true", help="write into ./.claude and ./.agents instead of your home directory")
|
|
58
|
+
ap.add_argument("--agents-md", action="store_true", help="insert a marked block into ./AGENTS.md instead of installing a skill")
|
|
59
|
+
ap.add_argument("--force", action="store_true")
|
|
60
|
+
a = ap.parse_args(argv)
|
|
61
|
+
if a.agents_md: print(write_agents_md(Path("AGENTS.md")))
|
|
62
|
+
else:
|
|
63
|
+
for d in skill_dirs(a.target, a.project): print(write_skill(d, a.force))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""moru-narrow: recommend the files and lines to check after `rg -w <name>`, without missing any.
|
|
3
|
+
|
|
4
|
+
Static, no model calls, standard library only. Kotlin + Gradle first.
|
|
5
|
+
Filters only DEMOTE files into tiers; `--all` always lists everything rg found.
|
|
6
|
+
|
|
7
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json]
|
|
8
|
+
"""
|
|
9
|
+
import argparse, json, os, re, subprocess, sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
STRONG = ("use", "override", "import")
|
|
13
|
+
TEST = re.compile(r"/(src/)?\w*[Tt]est\w*/")
|
|
14
|
+
PKG = re.compile(r"^package\s+([\w.]+)", re.M)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_test(rel): return bool(TEST.search("/" + rel)) or rel.endswith("Test.kt")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Repo:
|
|
21
|
+
def __init__(self, root):
|
|
22
|
+
self.root = Path(root).resolve(); self.src = {}
|
|
23
|
+
self.build = {} # module dir -> set(dependency dirs)
|
|
24
|
+
for g in list(self.root.rglob("build.gradle.kts")) + list(self.root.rglob("build.gradle")):
|
|
25
|
+
if "/build/" in str(g): continue
|
|
26
|
+
d = g.parent; base = self._settings_root(d)
|
|
27
|
+
deps = {(base / p.strip(":").replace(":", "/")).relative_to(self.root).as_posix()
|
|
28
|
+
for p in re.findall(r'project\(["\'](:[\w:.-]+)["\']\)', g.read_text(errors="ignore"))}
|
|
29
|
+
self.build[d.relative_to(self.root).as_posix()] = deps
|
|
30
|
+
self.reach = {m: self._closure(m) for m in self.build}
|
|
31
|
+
|
|
32
|
+
def _settings_root(self, d):
|
|
33
|
+
cur = d
|
|
34
|
+
while True:
|
|
35
|
+
if (cur / "settings.gradle.kts").exists() or (cur / "settings.gradle").exists(): return cur
|
|
36
|
+
if cur == self.root or cur == cur.parent: return self.root
|
|
37
|
+
cur = cur.parent
|
|
38
|
+
|
|
39
|
+
def _closure(self, m):
|
|
40
|
+
seen, st = set(), [m]
|
|
41
|
+
while st:
|
|
42
|
+
for y in self.build.get(st.pop(), ()):
|
|
43
|
+
if y not in seen: seen.add(y); st.append(y)
|
|
44
|
+
return seen
|
|
45
|
+
|
|
46
|
+
def text(self, rel):
|
|
47
|
+
if rel not in self.src: self.src[rel] = (self.root / rel).read_text(errors="ignore")
|
|
48
|
+
return self.src[rel]
|
|
49
|
+
|
|
50
|
+
def module(self, rel):
|
|
51
|
+
cur = Path(rel).parent
|
|
52
|
+
while str(cur) != ".":
|
|
53
|
+
if cur.as_posix() in self.build: return cur.as_posix()
|
|
54
|
+
cur = cur.parent
|
|
55
|
+
|
|
56
|
+
def package(self, rel):
|
|
57
|
+
m = PKG.search(self.text(rel)); return m.group(1) if m else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def role(line, name):
|
|
61
|
+
s = line.strip()
|
|
62
|
+
if s.startswith(("//", "*", "/*")): return "comment"
|
|
63
|
+
if re.match(r"import\s", s): return "import" if s.endswith("." + name) or " as " in s else "import-other"
|
|
64
|
+
if re.search(rf"\b(val|var)\s+{name}\b", s) and not re.search(rf"\b{name}\s*\(", s): return "property"
|
|
65
|
+
if re.search(rf"\bfun\s+(?:<[^>]*>\s*)?(?:[\w.<>?]+\.)?{name}\s*[(<]", s) or re.search(rf"\b(?:class|interface|object|typealias)\s+{name}\b", s):
|
|
66
|
+
return "override" if "override" in s else "declaration"
|
|
67
|
+
if re.search(rf"(?<![\w\"'])(?:\.|::|\b){name}\s*(?:\(|\{{|$|\)|,)", s): return "use"
|
|
68
|
+
if re.search(rf"\b{name}\s*=", s): return "named-arg"
|
|
69
|
+
return "other"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call_range(lines, i):
|
|
73
|
+
"""1-based line i; extend to the closing paren of the call that starts there (max 40 lines)."""
|
|
74
|
+
depth, seen = 0, False
|
|
75
|
+
for j in range(i - 1, min(len(lines), i + 39)):
|
|
76
|
+
for ch in lines[j]:
|
|
77
|
+
if ch == "(": depth += 1; seen = True
|
|
78
|
+
elif ch == ")": depth -= 1
|
|
79
|
+
if seen and depth <= 0: return i, j + 1
|
|
80
|
+
if not seen and j > i - 1: break
|
|
81
|
+
return i, i
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rg_hits(repo, name):
|
|
85
|
+
out = subprocess.run(["rg", "-nw", "--glob", "*.kt", name, "."], cwd=repo.root, capture_output=True, text=True).stdout.splitlines()
|
|
86
|
+
byfile = {}
|
|
87
|
+
for l in out:
|
|
88
|
+
m = re.match(r"\./(.+?\.kt):(\d+):(.*)", l)
|
|
89
|
+
if m and not is_test(m.group(1)): byfile.setdefault(m.group(1), []).append((int(m.group(2)), m.group(3)))
|
|
90
|
+
return byfile
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def recommend(repo, name, decl_files=None):
|
|
94
|
+
byfile = rg_hits(repo, name)
|
|
95
|
+
decls = []
|
|
96
|
+
for rel, hits in byfile.items():
|
|
97
|
+
for ln, tx in hits:
|
|
98
|
+
if role(tx, name) == "declaration" and (decl_files is None or rel in decl_files): decls.append((rel, ln))
|
|
99
|
+
if decl_files: decls = [(f, next((ln for ln, tx in byfile.get(f, []) if role(tx, name) == "declaration"), 0)) for f in decl_files]
|
|
100
|
+
dfiles = {d[0] for d in decls}
|
|
101
|
+
dmods = {repo.module(f) for f in dfiles}; dpkgs = {repo.package(f) for f in dfiles}
|
|
102
|
+
tiers = {1: {}, 2: {}, 3: {}}
|
|
103
|
+
for rel, hits in byfile.items():
|
|
104
|
+
lines = repo.text(rel).split("\n")
|
|
105
|
+
rows = [(ln, tx, role(tx, name)) for ln, tx in hits]
|
|
106
|
+
strong = [r for r in rows if r[2] in STRONG]
|
|
107
|
+
if rel in dfiles and (decl_files is not None or not strong): continue # a file that only declares it needs no check; with --decl the user already knows
|
|
108
|
+
m = repo.module(rel)
|
|
109
|
+
in_mod = m in dmods or any(dm in repo.reach.get(m, ()) for dm in dmods)
|
|
110
|
+
pk = repo.package(rel); src = repo.text(rel)
|
|
111
|
+
near = pk in dpkgs or any(re.search(r"^import\s+" + re.escape(p) + r"[.\w]*", src, re.M) for p in dpkgs if p)
|
|
112
|
+
tier = 1 if (strong and in_mod and near) else 2 if (strong and in_mod) else 3
|
|
113
|
+
tiers[tier][rel] = [{"line": ln, "to": call_range(lines, ln)[1], "role": r, "text": tx.strip()[:160]} for ln, tx, r in (strong or rows)]
|
|
114
|
+
return {"name": name, "declarations": decls, "tiers": tiers, "rg_files": len(byfile),
|
|
115
|
+
"rg_lines": sum(len(v) for v in byfile.values())}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render(res, show_all):
|
|
119
|
+
o = [f"{res['name']} declared: " + ("; ".join(f"{f}:{ln}" for f, ln in res["declarations"]) or "not found in non-test .kt")]
|
|
120
|
+
t1, t2, t3 = (res["tiers"][k] for k in (1, 2, 3))
|
|
121
|
+
o.append(f"TIER 1: check these ({len(t1)} files). rg found {res['rg_files']} files, {res['rg_lines']} lines.")
|
|
122
|
+
for rel, ls in sorted(t1.items()):
|
|
123
|
+
o.append(rel)
|
|
124
|
+
for r in ls: o.append(f" {r['line']}" + (f"-{r['to']}" if r["to"] > r["line"] else "") + f": {r['text']}")
|
|
125
|
+
for k, t, label in ((2, t2, "TIER 2: strong match, outside the declaring package or its imports"), (3, t3, "TIER 3: weak match or unreachable module")):
|
|
126
|
+
if show_all:
|
|
127
|
+
o.append(f"{label} ({len(t)} files)")
|
|
128
|
+
for rel, ls in sorted(t.items()):
|
|
129
|
+
o.append(rel)
|
|
130
|
+
for r in ls[:5]: o.append(f" {r['line']}: {r['text']}")
|
|
131
|
+
else:
|
|
132
|
+
o.append(f"hidden: tier {k} has {len(t)} files (use --all to list them)")
|
|
133
|
+
return "\n".join(o)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
138
|
+
ap.add_argument("name"); ap.add_argument("--repo", default="."); ap.add_argument("--decl", action="append")
|
|
139
|
+
ap.add_argument("--all", action="store_true"); ap.add_argument("--json", action="store_true")
|
|
140
|
+
a = ap.parse_args(); repo = Repo(a.repo)
|
|
141
|
+
res = recommend(repo, a.name, set(a.decl) if a.decl else None)
|
|
142
|
+
print(json.dumps(res, ensure_ascii=False) if a.json else render(res, a.all))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""moru-narrow for TypeScript (v3: two-hop wrapper rule + uses inside the declaring file): recommend the files and lines to check after `rg -w <name>`, without missing any.
|
|
3
|
+
|
|
4
|
+
Static, no model calls, standard library only. Filters only DEMOTE files into tiers; `--all` lists everything rg found.
|
|
5
|
+
Tier 1 = a strong use/import AND the file imports the declaring file (directly or through re-exporting barrel files).
|
|
6
|
+
|
|
7
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json]
|
|
8
|
+
"""
|
|
9
|
+
import argparse, json, os, re, subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
STRONG = ("use", "import")
|
|
13
|
+
TEST = re.compile(r"(\.test\.|\.spec\.|/__tests__/|/__mocks__/)")
|
|
14
|
+
EXTS = (".ts", ".tsx", ".mts")
|
|
15
|
+
FROM = re.compile(r"""(?:import|export)\s[^;'"]*?\bfrom\s*['"]([^'"]+)['"]""", re.S)
|
|
16
|
+
BARE = re.compile(r"""\bimport\s*['"]([^'"]+)['"]""")
|
|
17
|
+
DYN = re.compile(r"""(?:\bimport|\brequire)\s*\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
18
|
+
REEXPORT = re.compile(r"""\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]""", re.S)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_test(rel): return bool(TEST.search("/" + rel))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Repo:
|
|
25
|
+
def __init__(self, root):
|
|
26
|
+
self.root = Path(root).resolve(); self.src = {}; self._imp = {}; self._rex = {}
|
|
27
|
+
self.aliases = self._load_aliases()
|
|
28
|
+
|
|
29
|
+
def _load_aliases(self):
|
|
30
|
+
out = []
|
|
31
|
+
cfg = self.root / "tsconfig.json"
|
|
32
|
+
if cfg.exists():
|
|
33
|
+
raw = re.sub(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', lambda m: m.group(1) or "", cfg.read_text(errors="ignore"), flags=re.S)
|
|
34
|
+
raw = re.sub(r",(\s*[}\]])", r"\1", raw)
|
|
35
|
+
try:
|
|
36
|
+
co = json.loads(raw).get("compilerOptions", {}); base = co.get("baseUrl", ".")
|
|
37
|
+
for k, v in (co.get("paths") or {}).items():
|
|
38
|
+
if v: out.append((k.rstrip("*"), (Path(base) / v[0].rstrip("*")).as_posix().lstrip("./")))
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
def text(self, rel):
|
|
44
|
+
if rel not in self.src: self.src[rel] = (self.root / rel).read_text(errors="ignore")
|
|
45
|
+
return self.src[rel]
|
|
46
|
+
|
|
47
|
+
def resolve(self, frm, spec):
|
|
48
|
+
cands = []
|
|
49
|
+
if spec.startswith("."): cands.append(os.path.normpath(Path(frm).parent / spec))
|
|
50
|
+
else:
|
|
51
|
+
for pre, tgt in self.aliases:
|
|
52
|
+
if spec.startswith(pre): cands.append(os.path.normpath(Path(tgt) / spec[len(pre):]))
|
|
53
|
+
for c in cands:
|
|
54
|
+
c = str(Path(c))
|
|
55
|
+
for suffix in ("", *EXTS, ".d.ts", "/index.ts", "/index.tsx"):
|
|
56
|
+
p = c + suffix
|
|
57
|
+
if p.endswith(EXTS) and (self.root / p).is_file(): return p
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def imports(self, rel):
|
|
61
|
+
if rel not in self._imp:
|
|
62
|
+
t = self.text(rel); specs = set(FROM.findall(t)) | set(BARE.findall(t)) | set(DYN.findall(t))
|
|
63
|
+
self._imp[rel] = {r for r in (self.resolve(rel, s) for s in specs) if r}
|
|
64
|
+
return self._imp[rel]
|
|
65
|
+
|
|
66
|
+
def reexports(self, rel):
|
|
67
|
+
if rel not in self._rex:
|
|
68
|
+
self._rex[rel] = {r for r in (self.resolve(rel, s) for s in REEXPORT.findall(self.text(rel))) if r}
|
|
69
|
+
return self._rex[rel]
|
|
70
|
+
|
|
71
|
+
def providers(self, decl, files):
|
|
72
|
+
"""The declaring file plus every file that re-exports it, transitively."""
|
|
73
|
+
prov, grew = {decl}, True
|
|
74
|
+
while grew:
|
|
75
|
+
grew = False
|
|
76
|
+
for f in files:
|
|
77
|
+
if f not in prov and self.reexports(f) & prov: prov.add(f); grew = True
|
|
78
|
+
return prov
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def role(line, name):
|
|
82
|
+
s = line.strip()
|
|
83
|
+
if s.startswith(("//", "*", "/*")): return "comment"
|
|
84
|
+
if re.search(rf"\b(?:function\s*\*?|class|interface|type|enum)\s+{name}\b", s) or re.search(rf"\b(?:const|let|var)\s+{name}\b", s):
|
|
85
|
+
return "declaration"
|
|
86
|
+
if re.match(r"(?:import|export)\b", s) and (re.search(r"\bfrom\b", s) or s.endswith(("{", ","))):
|
|
87
|
+
return "import" if re.search(rf"\b{name}\b", s.split("from")[0]) else "import-other"
|
|
88
|
+
if re.search(rf"""(?<=['"`]){name}(?=['"`])""", s) and not re.search(rf"""(?<![\w'"`.]){name}(?![\w'"`])""", s): return "string"
|
|
89
|
+
if re.match(rf"{name}\s*\??:(?!:)", s) or re.search(rf"[{{,(]\s*{name}\s*\??:(?!:)", s): return "named-arg"
|
|
90
|
+
return "use"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def call_range(lines, i):
|
|
94
|
+
"""1-based line i; extend to the closing paren (or the end of a JSX opening tag) that starts there (max 40 lines)."""
|
|
95
|
+
depth, seen = 0, False
|
|
96
|
+
for j in range(i - 1, min(len(lines), i + 39)):
|
|
97
|
+
for ch in lines[j]:
|
|
98
|
+
if ch == "(": depth += 1; seen = True
|
|
99
|
+
elif ch == ")": depth -= 1
|
|
100
|
+
if seen and depth <= 0: return i, j + 1
|
|
101
|
+
if not seen and j > i - 1: break
|
|
102
|
+
if re.search(r"<\w", lines[i - 1]) and ">" not in lines[i - 1].replace("=>", ""):
|
|
103
|
+
for j in range(i, min(len(lines), i + 39)):
|
|
104
|
+
if ">" in lines[j].replace("=>", ""): return i, j + 1
|
|
105
|
+
return i, i
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def rg_hits(repo, name):
|
|
109
|
+
out = subprocess.run(["rg", "-nw", "-g", "*.ts", "-g", "*.tsx", "-g", "*.mts", "-g", "!node_modules", "-g", "!.next", "--", name, "."],
|
|
110
|
+
cwd=repo.root, capture_output=True, text=True).stdout.splitlines()
|
|
111
|
+
byfile = {}
|
|
112
|
+
for l in out:
|
|
113
|
+
m = re.match(r"\./(.+?\.(?:ts|tsx|mts)):(\d+):(.*)", l)
|
|
114
|
+
if m and not is_test(m.group(1)): byfile.setdefault(m.group(1), []).append((int(m.group(2)), m.group(3)))
|
|
115
|
+
return byfile
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def recommend(repo, name, decl_files=None):
|
|
119
|
+
byfile = rg_hits(repo, name)
|
|
120
|
+
decls = []
|
|
121
|
+
for rel, hits in byfile.items():
|
|
122
|
+
for ln, tx in hits:
|
|
123
|
+
if role(tx, name) == "declaration" and (decl_files is None or rel in decl_files): decls.append((rel, ln))
|
|
124
|
+
if decl_files: decls = [(f, next((ln for ln, tx in byfile.get(f, []) if role(tx, name) == "declaration"), 0)) for f in decl_files]
|
|
125
|
+
dfiles = {d[0] for d in decls}
|
|
126
|
+
prov = set()
|
|
127
|
+
for d in dfiles: prov |= repo.providers(d, _all_files(repo))
|
|
128
|
+
tiers = {1: {}, 2: {}, 3: {}}
|
|
129
|
+
for rel, hits in byfile.items():
|
|
130
|
+
lines = repo.text(rel).split("\n")
|
|
131
|
+
rows = [(ln, tx, role(tx, name)) for ln, tx in hits]
|
|
132
|
+
strong = [r for r in rows if r[2] in STRONG]
|
|
133
|
+
if rel in dfiles and not strong: continue # v3: a declaring file with other uses is kept, even when --decl names it
|
|
134
|
+
near = rel in dfiles or bool(repo.imports(rel) & prov) # v3: calls inside the declaring file are near
|
|
135
|
+
# v2: a file that imports a wrapper (a file importing the declaring file) and calls `X.name` is also near
|
|
136
|
+
if not near and strong and any(re.search(rf"\.{re.escape(name)}\b", r[1]) for r in strong):
|
|
137
|
+
near = any(repo.imports(g) & prov for g in repo.imports(rel))
|
|
138
|
+
tier = 1 if (strong and near) else 2 if strong else 3
|
|
139
|
+
tiers[tier][rel] = [{"line": ln, "to": call_range(lines, ln)[1], "role": r, "text": tx.strip()[:160]} for ln, tx, r in (strong or rows)]
|
|
140
|
+
return {"name": name, "declarations": decls, "tiers": tiers, "rg_files": len(byfile),
|
|
141
|
+
"rg_lines": sum(len(v) for v in byfile.values())}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_ALL = {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _all_files(repo):
|
|
148
|
+
if repo.root not in _ALL:
|
|
149
|
+
out = subprocess.run(["rg", "--files", "-g", "*.ts", "-g", "*.tsx", "-g", "*.mts", "-g", "!node_modules", "-g", "!.next"], cwd=repo.root, capture_output=True, text=True).stdout.split()
|
|
150
|
+
_ALL[repo.root] = [f for f in out if not is_test(f) and "export" in repo.text(f)]
|
|
151
|
+
return _ALL[repo.root]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render(res, show_all):
|
|
155
|
+
o = [f"{res['name']} declared: " + ("; ".join(f"{f}:{ln}" for f, ln in res["declarations"]) or "not found in non-test .ts/.tsx")]
|
|
156
|
+
t1, t2, t3 = (res["tiers"][k] for k in (1, 2, 3))
|
|
157
|
+
o.append(f"TIER 1: check these ({len(t1)} files). rg found {res['rg_files']} files, {res['rg_lines']} lines.")
|
|
158
|
+
for rel, ls in sorted(t1.items()):
|
|
159
|
+
o.append(rel)
|
|
160
|
+
for r in ls: o.append(f" {r['line']}" + (f"-{r['to']}" if r["to"] > r["line"] else "") + f": {r['text']}")
|
|
161
|
+
for k, t, label in ((2, t2, "TIER 2: strong match in a file that does not import the declaring file"), (3, t3, "TIER 3: weak match (comment, string, key)")):
|
|
162
|
+
if show_all:
|
|
163
|
+
o.append(f"{label} ({len(t)} files)")
|
|
164
|
+
for rel, ls in sorted(t.items()):
|
|
165
|
+
o.append(rel)
|
|
166
|
+
for r in ls[:5]: o.append(f" {r['line']}: {r['text']}")
|
|
167
|
+
else:
|
|
168
|
+
o.append(f"hidden: tier {k} has {len(t)} files (use --all to list them)")
|
|
169
|
+
return "\n".join(o)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
174
|
+
ap.add_argument("name"); ap.add_argument("--repo", default="."); ap.add_argument("--decl", action="append")
|
|
175
|
+
ap.add_argument("--all", action="store_true"); ap.add_argument("--json", action="store_true")
|
|
176
|
+
a = ap.parse_args(); repo = Repo(a.repo)
|
|
177
|
+
res = recommend(repo, a.name, set(a.decl) if a.decl else None)
|
|
178
|
+
print(json.dumps(res, ensure_ascii=False) if a.json else render(res, a.all))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moru-narrow
|
|
3
|
+
Version: 26.45.0
|
|
4
|
+
Summary: Narrow `rg -w <Name>` to the files and lines you must edit after a signature change (Kotlin+Gradle, TypeScript).
|
|
5
|
+
Author: taehwandev
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/taehwandev/moru-narrow
|
|
8
|
+
Project-URL: Issues, https://github.com/taehwandev/moru-narrow/issues
|
|
9
|
+
Keywords: ripgrep,rg,refactoring,coding-agent,kotlin,typescript,claude-code,codex
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# moru-narrow
|
|
22
|
+
|
|
23
|
+
**English** | [한국어](README.ko.md)
|
|
24
|
+
|
|
25
|
+
> **rg tells you every line that mentions a name. moru-narrow tells you which of those lines you must edit.** It keeps every hit; it only changes the order and what is shown first.
|
|
26
|
+
|
|
27
|
+
`moru-narrow` post-processes `rg -w <Name>` for coding agents and humans. It keeps every hit, ranks them, and prints the
|
|
28
|
+
files and **line ranges** to check first. No index, no database, no model calls, no daemon. Python standard library and
|
|
29
|
+
[ripgrep](https://github.com/BurntSushi/ripgrep) only.
|
|
30
|
+
|
|
31
|
+
> Scope: **signature changes** (add or change a parameter, then find what breaks). It is not a general `rg` replacement: for renaming a variable, tracing a logic bug or searching a config string, use plain `rg`.
|
|
32
|
+
>
|
|
33
|
+
> Status: alpha / experimental. MIT license. The examples use the `moru-narrow` command (alias `mnarrow`); from a clone, run `python3 -m moru_narrow` instead.
|
|
34
|
+
|
|
35
|
+
## In short
|
|
36
|
+
|
|
37
|
+
- **What it does:** after `rg -w name`, it lists the few files and line ranges you must edit, ranked, and hides the rest behind `--all`. Nothing is dropped.
|
|
38
|
+
- **Does it miss files?** Checked against the compiler: in all 217 Kotlin tasks every truth file was listed (rules were partly tuned on them), and 191 of 194 tasks (99.3% of files) on a fresh TypeScript set. The rare misses are files that never mention the name.
|
|
39
|
+
- **Does it help a coding agent?** In 6 test setups the compile success rate never dropped below the baseline. Context tokens fell by 9.8% to 34.5% in five setups; in the sixth (Codex on TypeScript) the change cannot be told apart from noise.
|
|
40
|
+
- **The catch:** the savings appeared only when the agent was also told not to re-run `rg`. Without that sentence, cost went up 10.3% [+0.0003, +0.0009 USD per task] in one test (Kotlin, Haiku, 57 tasks).
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
$ moru-narrow getGlobalTheme --repo ./my-next-app
|
|
44
|
+
getGlobalTheme declared: src/lib/theme-settings.ts:28
|
|
45
|
+
TIER 1: check these (3 files). rg found 4 files, 6 lines.
|
|
46
|
+
src/app/[locale]/(walled)/manage/(protected)/admin/settings/page.tsx
|
|
47
|
+
5: import { getGlobalTheme, saveGlobalTheme, type ThemeMode } from '@/lib/theme-settings';
|
|
48
|
+
19-22: getGlobalTheme({ fresh: true }).then(t => {
|
|
49
|
+
...
|
|
50
|
+
hidden: tier 2 has 0 files (use --all to list them)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Needs Python 3.9+ and [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) on your `PATH`. No other dependency. It does not need Moru Core or any other tool.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pipx install git+https://github.com/taehwandev/moru-narrow # or: uv tool install git+https://... / pip install git+https://...
|
|
59
|
+
moru-narrow <Name> --repo /path/to/your/project # Kotlin+Gradle or TypeScript, auto-detected
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Without installing, from a clone: `git clone https://github.com/taehwandev/moru-narrow && cd moru-narrow && python3 -m moru_narrow <Name> --repo ...`.
|
|
63
|
+
|
|
64
|
+
Not published on PyPI or npm yet. The `pip install git+...` route was tested in a clean virtualenv on macOS with Python 3.9.6; other platforms and Python versions are untested.
|
|
65
|
+
|
|
66
|
+
## Use with your coding agent
|
|
67
|
+
|
|
68
|
+
The tool only helps an agent when the agent is told how to use it (see the savings section: without the "do not re-run rg" sentence cost went up). One command registers that instruction:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
moru-narrow --install # agent skill in ~/.claude/skills/ (Claude Code) and ~/.agents/skills/ (Codex and other agent-skills tools)
|
|
72
|
+
moru-narrow --install --project # same, but into ./.claude and ./.agents so your team shares it via git
|
|
73
|
+
moru-narrow --install --agents-md # no skill support (e.g. Cursor): add a marked block to ./AGENTS.md
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Re-running is safe: an existing `SKILL.md` is kept (`--force` overwrites it), and the `AGENTS.md` block is replaced in place without touching your other text. `--target claude|agents` limits the skill install to one location. The installed text is based on the instruction used in the agent tests (worded differently, and extended as noted below):
|
|
77
|
+
|
|
78
|
+
> When a task changes a function or constructor signature (adds or changes a parameter), run `moru-narrow <Name> --repo <project root>` once and edit the call sites it lists under TIER 1. Do not run `rg` again for the same name. Use `--all` only if the list looks incomplete. For anything else (renames, logic bugs, config strings), or if moru-narrow reports an error or an unsupported language, use plain `rg`.
|
|
79
|
+
|
|
80
|
+
The exact wording was not the one measured, and the last sentence (use plain `rg` for other tasks and unsupported languages) is new: the tests only contained signature-change tasks and a prompt written for them. Any agent that can run a shell command can also call `moru-narrow` directly.
|
|
81
|
+
|
|
82
|
+
## Why
|
|
83
|
+
|
|
84
|
+
Changing a function signature means finding every place that breaks. `rg -w name` finds all of them, plus comments,
|
|
85
|
+
strings, same-named functions in other modules and unrelated files. On a Kotlin monorepo it returned **60 files for a
|
|
86
|
+
change that broke 3 to 4**. An agent then reads all of them.
|
|
87
|
+
|
|
88
|
+
A code-graph index was the obvious fix. We measured a syntax-level code-graph index on the same Kotlin repo and it did not
|
|
89
|
+
pay off at the agent level: no measurable cost difference with location-only output, and about +45.5% cost when it returned source code (that run also carried MCP connection overhead, so part of the increase is not the graph itself).
|
|
90
|
+
It also needs an index build and a sync after every edit (stale until synced). `moru-narrow` has no build step and no
|
|
91
|
+
staleness, because it runs `rg` live.
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
1. `rg -nw <Name>` collects hits (test files are excluded).
|
|
96
|
+
2. Each line gets a role: `use`, `import`, `override`, `declaration`, `comment`, `string`, `named-arg`, ...
|
|
97
|
+
3. A file is **tier 1** when it has a strong role (`use`/`import`/`override`) **and** can see the declaration:
|
|
98
|
+
- Kotlin: the module is reachable through Gradle `project()` edges, and the file is in the same package or imports it.
|
|
99
|
+
- TypeScript: the file imports the declaring file (relative paths and `tsconfig` aliases, through re-exporting
|
|
100
|
+
barrel files), or imports a wrapper that does and calls `X.name`, or is the declaring file itself.
|
|
101
|
+
4. Everything else is demoted to tier 2/3. **Filters only demote. `--all` always lists everything `rg` found.**
|
|
102
|
+
5. Multi-line calls are returned as ranges (`19-22`), because compilers report errors at argument lines.
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
moru-narrow <Name> [--repo DIR] [--decl FILE] [--all] [--json] [--lang kotlin|ts]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Language is auto-detected (`tsconfig.json`, or a Gradle build).
|
|
109
|
+
|
|
110
|
+
## How to read the numbers
|
|
111
|
+
|
|
112
|
+
| Term | What it means here |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| **Truth files** | Production files that stop compiling after we add a required first parameter (`traceId`) to a function. The compiler decides, not us. |
|
|
115
|
+
| **Recall** | Whether the truth files appear in tier 1. The table counts tasks where all of them do (`23/23`); TypeScript rows also give files (`428/431` = 3 truth files were not listed). |
|
|
116
|
+
| **Tier 1** | The files moru-narrow tells you to read first. |
|
|
117
|
+
| **`rg -w` files / lines** | What plain ripgrep returns for the same name. This is the baseline to beat. |
|
|
118
|
+
| **Fresh set** | Candidates drawn after the rules were frozen. Nothing was tuned on them, so they are the fair test. |
|
|
119
|
+
| **Baseline vs. with moru-narrow** (agent tests) | Same coding agent, same task, same prompt. Baseline may use `rg`, `ls` and file reads. The other condition also gets the tool and the sentence "edit from the list, do not re-run rg for the same name". |
|
|
120
|
+
| **Compile pass** | The project compiles after the agent's edit (Kotlin: Gradle, TypeScript: `tsc --noEmit`). TypeScript runs also require that the declaration really gained the parameter, because an agent that changes nothing would otherwise pass. |
|
|
121
|
+
| **Context tokens** | Input tokens the model processed during the whole run, cached ones included. A proxy for how much it read. |
|
|
122
|
+
| **Cost** | API-equivalent price estimate reported by the Claude CLI, not an actual bill. Codex does not report it, shown as n/a. |
|
|
123
|
+
| **`[a, b]` interval** | 95% range from a task-paired bootstrap (10,000 resamples): tasks are resampled and the two conditions are compared on the same task. If the range includes 0, treat the difference as noise. |
|
|
124
|
+
| **tasks x reps** | Number of tasks times repetitions per task. Repetitions of one task are averaged before the comparison. |
|
|
125
|
+
|
|
126
|
+
## Does it miss files?
|
|
127
|
+
|
|
128
|
+
Ground truth is the **compiler**: add a required first parameter `traceId: String` / `traceId: string` to a function (or
|
|
129
|
+
Kotlin constructor), compile, and take the production files that fail as the truth.
|
|
130
|
+
|
|
131
|
+
| Set | Tasks | `rg -w` files / lines | moru-narrow tier 1 files / lines | Tasks with every truth file in tier 1 |
|
|
132
|
+
| --- | ---: | --- | --- | ---: |
|
|
133
|
+
| Kotlin, 23 selected noisy functions | 23 | 60.4 / 139 | 8.8 / 18 | 23/23 |
|
|
134
|
+
| Kotlin, held-out functions | 120 | 68.3 / 281 | 3.3 / 5 | 120/120 |
|
|
135
|
+
| Kotlin, held-out noisy subset (rg >= 20 files and >= 2x the truth) | 57 | 132.9 / 571 | 3.8 / 7 | 57/57 |
|
|
136
|
+
| Kotlin, constructor changes | 74 | 14.9 / 36 | 8.8 / 17 | 74/74 |
|
|
137
|
+
| TypeScript, **fresh** functions (v3 rules frozen first) | 194 | 3.5 / 7 | 2.5 / 6 | 191/194 (files: 428/431, 99.3%) |
|
|
138
|
+
| TypeScript, fresh, noisy functions (rg >= 5 files) | 28 | 9.5 / 20 | 8.2 / 18 | 28/28 (files: 193/193) |
|
|
139
|
+
|
|
140
|
+
Read these carefully:
|
|
141
|
+
|
|
142
|
+
- Kotlin rules were adjusted after the first held-out misses (two regex bugs), so the Kotlin sets are **not fully
|
|
143
|
+
independent**. The TypeScript fresh set is: rules were frozen (sha256 recorded) before it was scored.
|
|
144
|
+
- TypeScript misses that remain are cases where the failing file **never mentions the name** (the type flows through
|
|
145
|
+
another function). No `rg`-based tool can find those.
|
|
146
|
+
- Line level: error lines inside the recommended ranges were 90% (Kotlin functions), 96% within +-8 lines (Kotlin
|
|
147
|
+
constructors) and 90% (TypeScript functions). Use ranges as hints, files as the guarantee.
|
|
148
|
+
- The TypeScript compiler truth is built with the TypeScript API in memory against one private Next.js repo; Kotlin on
|
|
149
|
+
one private Compose Multiplatform repo. Neither repo is published here.
|
|
150
|
+
|
|
151
|
+
## Does it save an agent anything?
|
|
152
|
+
|
|
153
|
+
Same task, same prompt template, two conditions, task-paired bootstrap (10,000 resamples, 95% interval). Local logins
|
|
154
|
+
only. Cost is the API-equivalent estimate reported by the CLI.
|
|
155
|
+
|
|
156
|
+
| Setup | Compile pass (baseline -> with moru-narrow) | Context tokens | Cost |
|
|
157
|
+
| --- | --- | --- | --- |
|
|
158
|
+
| Kotlin, Haiku, 23 tasks x 2 | 46/46 -> 46/46 | **-34.5%** [-107k, -37k] | **-24.2%** |
|
|
159
|
+
| Kotlin, Haiku, 57 tasks x 2 | 112/114 -> 114/114 | **-19.2%** [-36k, -18k] | **-16.4%** |
|
|
160
|
+
| Kotlin, Codex terra, 23 x 2 | 44/46 -> 45/46 | **-14.2%** [-22.8%, -6.2%] | n/a |
|
|
161
|
+
| Kotlin, Codex terra, 57 x 2 | 112/114 -> 114/114 | **-9.8%** [-14.6%, -5.3%] | n/a |
|
|
162
|
+
| TypeScript, Haiku, 28 x 2 | 56/56 -> 56/56 | **-22.2%** [-29.4%, -15.3%] | **-11.1%** [-17.3%, -4.3%] |
|
|
163
|
+
| TypeScript, Codex terra, 28 x 2 | 56/56 -> 56/56 | -6.5% [-13.5%, 0.0%] (touches 0) | n/a |
|
|
164
|
+
|
|
165
|
+
**The agent instruction matters as much as the tool.** In the first Kotlin experiment the agent got the same list but
|
|
166
|
+
kept re-running `rg` afterwards, and cost went **up** 10.3% (context +12.8%) on the larger Haiku set. Adding one
|
|
167
|
+
sentence, "edit the call sites from this list without re-running rg for the same name; use --all only if the list looks
|
|
168
|
+
incomplete", changed that: with it, `rg` calls per run fell from 2.4 to 0.02 (Kotlin, Codex, 23 tasks) and from 2.8 to 0.5
|
|
169
|
+
(TypeScript, Haiku), and it produced the table above. That revised prompt was written after seeing the first
|
|
170
|
+
results, so treat the savings as exploratory, and ship the instruction together with the tool.
|
|
171
|
+
|
|
172
|
+
**What we learned the hard way.** The first TypeScript agent run failed 10 more tasks than the baseline (33/44 vs 43/44).
|
|
173
|
+
Every file the compiler flagged was in the list; the miss was calls *inside the declaring file*, which the first
|
|
174
|
+
version hid because a file does not import itself. The offline truth had also excluded the declaring file, so the offline
|
|
175
|
+
check could not see it. Fix: v3 keeps those lines in tier 1. Lesson: validate with the real agent loop, not only offline.
|
|
176
|
+
|
|
177
|
+
## Limits
|
|
178
|
+
|
|
179
|
+
- Languages: Kotlin + Gradle and TypeScript. Each language needs a small profile (role patterns, how to see a
|
|
180
|
+
declaration). Dynamic languages need a different ground truth.
|
|
181
|
+
- Verified on **one change shape** (adding a required first parameter) and **one repository per language**.
|
|
182
|
+
- React components are out of scope (a positional first parameter does not exist in JSX).
|
|
183
|
+
- Savings depend on the model. Codex on the TypeScript repo showed no clear reduction (-6.5%, interval touches 0). There
|
|
184
|
+
`rg` was already precise (3.5 files on average for functions) and the agent ran only ~3 commands per task; we did not test why.
|
|
185
|
+
- Sample sizes are small (28 to 57 tasks, 2 repetitions per task). In the baseline every run passed in TypeScript, so pass rate cannot show an improvement there, only that nothing broke.
|
|
186
|
+
- The agent tests sent private source to the model providers (Anthropic, OpenAI) through the author's own local CLI logins.
|
|
187
|
+
|
|
188
|
+
## Reproduce
|
|
189
|
+
|
|
190
|
+
The evaluation harnesses (agent runners, compiler-truth generators, scorers) are not in this repository yet: they carry local paths and private repository details and need a scrub first. `results/` holds the aggregate tables. The method is described above: compiler errors after adding a required first parameter are the ground truth; rules were frozen (hash recorded) before the fresh TypeScript set was scored.
|
|
191
|
+
|
|
192
|
+
## Versioning
|
|
193
|
+
|
|
194
|
+
Weekly CalVer `YY.WW.N` (scheme `calver-weekly`), for example `26.45.0`: two-digit year, week number, and a release count that starts at `0` each week and counts published packages. Weeks follow the Korean calendar (Asia/Seoul, Monday-start weeks). Git tags use a `v` prefix (`v26.45.0`); the package metadata omits it. Python normalises a zero-padded week, so week 5 appears as `26.5.0` on PyPI. This is a date-based version, not a compatibility signal: while the tool is alpha, read the commit log before upgrading.
|
|
195
|
+
|
|
196
|
+
## Status
|
|
197
|
+
|
|
198
|
+
Alpha. Not on PyPI or npm. No unit tests yet. The ranking was validated against compiler ground truth (see above); `--install` was only checked by hand in a temporary home directory (fresh install, re-run, `--project`, `--agents-md` idempotence). Issues and results from other repositories are welcome.
|
|
199
|
+
|
|
200
|
+
License: [MIT](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
moru_narrow/__init__.py
|
|
5
|
+
moru_narrow/__main__.py
|
|
6
|
+
moru_narrow/install.py
|
|
7
|
+
moru_narrow/kotlin.py
|
|
8
|
+
moru_narrow/typescript.py
|
|
9
|
+
moru_narrow.egg-info/PKG-INFO
|
|
10
|
+
moru_narrow.egg-info/SOURCES.txt
|
|
11
|
+
moru_narrow.egg-info/dependency_links.txt
|
|
12
|
+
moru_narrow.egg-info/entry_points.txt
|
|
13
|
+
moru_narrow.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moru_narrow
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "moru-narrow"
|
|
3
|
+
version = "26.45.0"
|
|
4
|
+
description = "Narrow `rg -w <Name>` to the files and lines you must edit after a signature change (Kotlin+Gradle, TypeScript)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "taehwandev" }]
|
|
9
|
+
keywords = ["ripgrep", "rg", "refactoring", "coding-agent", "kotlin", "typescript", "claude-code", "codex"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Software Development",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/taehwandev/moru-narrow"
|
|
21
|
+
Issues = "https://github.com/taehwandev/moru-narrow/issues"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
moru-narrow = "moru_narrow.__main__:main"
|
|
25
|
+
mnarrow = "moru_narrow.__main__:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=61"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["moru_narrow"]
|