scopecairn 0.9.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -63
- package/dist/agentsmd-GS2DHP65.js +13 -0
- package/dist/chunk-2BIPX6YJ.js +326 -0
- package/dist/{chunk-BTRFJORI.js → chunk-AJFANTT3.js} +88 -12
- package/dist/{chunk-4ZV67SRY.js → chunk-G5RA43OH.js} +31 -2
- package/dist/chunk-OPA4TMAT.js +24 -0
- package/dist/chunk-PR3BGBXP.js +217 -0
- package/dist/chunk-ZV4BSIQM.js +73 -0
- package/dist/cli.js +3241 -1179
- package/dist/contextBuilder-UBA3TZRU.js +11 -0
- package/dist/extractor-665DZW2D.js +20 -0
- package/dist/{scope-HWYABB6A.js → scope-RA4EDS4A.js} +1 -1
- package/dist/search-OLYYFEQV.js +28 -0
- package/package.json +1 -1
- package/skills/scopecairn/SKILL.md +2 -2
- package/dist/contextBuilder-2AHF67V5.js +0 -8
package/README.md
CHANGED
|
@@ -24,15 +24,15 @@ No MCP server, no embeddings, no network calls, no telemetry.
|
|
|
24
24
|
Definition of Done, so the agent changes only what matters.
|
|
25
25
|
- **Change impact** — direct, indirect (2-hop), tests, and UI components
|
|
26
26
|
affected by a file or symbol.
|
|
27
|
-
- **Call path tracing** — `scopecairn
|
|
27
|
+
- **Call path tracing** — `scopecairn graph <A> --to <B>` shows the shortest
|
|
28
28
|
multi-hop call/dependency chain between two symbols.
|
|
29
29
|
- **Centrality ranking** — weighted PageRank + betweenness cached per
|
|
30
|
-
symbol; `context` surfaces critical hotspots, `doctor` flags
|
|
30
|
+
symbol; `context` surfaces critical hotspots, `status --doctor` flags
|
|
31
31
|
single points of failure.
|
|
32
32
|
- **Community detection** — Louvain clustering finds real modules by
|
|
33
33
|
link density (not just folders); `graph` shows each symbol's community.
|
|
34
34
|
- **Cycle detection** — Tarjan SCC finds circular dependencies;
|
|
35
|
-
reported by `doctor`, warned in `context`, listed in `GRAPH.md`.
|
|
35
|
+
reported by `status --doctor` and `verify`, warned in `context`, listed in `GRAPH.md`.
|
|
36
36
|
- **Auto-refresh** — `context` and `impact` re-index changed files first;
|
|
37
37
|
run `scan` after editing to close the loop.
|
|
38
38
|
- **Framework adapters** — Next.js (App + Pages Router routes),
|
|
@@ -40,8 +40,8 @@ No MCP server, no embeddings, no network calls, no telemetry.
|
|
|
40
40
|
- **Multi-language** — TypeScript, JavaScript, Python, Java, Go, Rust,
|
|
41
41
|
PHP, C#, C/C++, Ruby, HTML, Vue SFC (plus config/schema files as
|
|
42
42
|
graph nodes).
|
|
43
|
-
- **Multi-agent setup** — skills for Claude Code (
|
|
44
|
-
|
|
43
|
+
- **Multi-agent setup** — skills for Claude Code (project skill),
|
|
44
|
+
Antigravity, Cursor, Windsurf, Copilot, Kiro, and an OpenCode
|
|
45
45
|
slash command. ScopeCairn never touches your `AGENTS.md`/`CLAUDE.md` —
|
|
46
46
|
those hold your repo's details, not tool instructions.
|
|
47
47
|
- **`GRAPH.md` knowledge file** — `.scopecairn/GRAPH.md` is a budgeted
|
|
@@ -58,7 +58,7 @@ No MCP server, no embeddings, no network calls, no telemetry.
|
|
|
58
58
|
click-for-details side panel (callers, callees, centrality scores).
|
|
59
59
|
Nodes sized by symbols/PageRank, colored by Louvain community.
|
|
60
60
|
Pan, zoom, hover inspect. Double-click to open, no server.
|
|
61
|
-
-
|
|
61
|
+
- **Graph export for external tools** — `scopecairn graph --export
|
|
62
62
|
mermaid|graphml|dot|json` writes `.scopecairn/graph.<ext>` for PR
|
|
63
63
|
diagrams (Mermaid), Gephi/Cytoscape (GraphML), Graphviz (DOT),
|
|
64
64
|
or custom scripts (JSON).
|
|
@@ -74,8 +74,7 @@ Requires Node.js ≥ 20.
|
|
|
74
74
|
Upgrade later with:
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
|
|
78
|
-
scopecairn update --check # only check for a new version
|
|
77
|
+
npm install -g scopecairn@latest
|
|
79
78
|
```
|
|
80
79
|
|
|
81
80
|
## Agent-driven setup
|
|
@@ -88,8 +87,8 @@ it runs project setup itself.
|
|
|
88
87
|
npx skills add <github-user>/scopecairn -g
|
|
89
88
|
```
|
|
90
89
|
(fallback without a skill: add one line to your global agent
|
|
91
|
-
instructions — `~/.claude/CLAUDE.md` or equivalent:
|
|
92
|
-
writes "scopecairn \<path\>", run `scopecairn init \<path
|
|
90
|
+
instructions — `~/.claude/CLAUDE.md` or equivalent: _When the user
|
|
91
|
+
writes "scopecairn \<path\>", run `scopecairn init \<path\>`._)
|
|
93
92
|
2. In any project chat, type:
|
|
94
93
|
```
|
|
95
94
|
scopecairn ./
|
|
@@ -111,81 +110,75 @@ Claude Code (project skill, committed with the repo):
|
|
|
111
110
|
scopecairn claude install
|
|
112
111
|
```
|
|
113
112
|
|
|
114
|
-
Or as a plugin from the bundled marketplace (this repo):
|
|
115
|
-
|
|
116
|
-
```
|
|
117
|
-
/plugin marketplace add <github-user>/scopecairn
|
|
118
|
-
/plugin install scopecairn@scopecairn
|
|
119
|
-
```
|
|
120
|
-
|
|
121
113
|
After setup, your agent calls ScopeCairn automatically on every coding task.
|
|
122
|
-
To query manually:
|
|
114
|
+
To query manually (replace `src/indexer.ts` or `buildContext` with files/symbols from your codebase):
|
|
123
115
|
|
|
124
116
|
```bash
|
|
125
|
-
scopecairn
|
|
126
|
-
scopecairn context
|
|
127
|
-
scopecairn impact src/
|
|
128
|
-
scopecairn
|
|
129
|
-
scopecairn
|
|
130
|
-
scopecairn
|
|
131
|
-
scopecairn
|
|
132
|
-
scopecairn doctor
|
|
117
|
+
scopecairn scan # index repository (--rebuild, --clean, --watch)
|
|
118
|
+
scopecairn context "tambah fitur auth" # AI agent task context + scope
|
|
119
|
+
scopecairn impact src/indexer.ts # blast radius & affected tests (--run-tests)
|
|
120
|
+
scopecairn graph buildContext # inspect relations, path (--to), export (--export)
|
|
121
|
+
scopecairn verify # pre-commit safety guard (--diff, --arch)
|
|
122
|
+
scopecairn status # index statistics & health checks (--doctor, --html)
|
|
123
|
+
scopecairn read symbol indexFileSymbols # symbol-level source excerpt
|
|
133
124
|
```
|
|
134
125
|
|
|
135
126
|
Per-agent installers (instead of `init --agents …`):
|
|
136
127
|
|
|
137
128
|
```bash
|
|
138
129
|
scopecairn antigravity install # Skill + Workflow + allowlist guide
|
|
139
|
-
scopecairn claude install # project skill .claude/skills (also: cursor, windsurf, copilot
|
|
130
|
+
scopecairn claude install # project skill .claude/skills (also: cursor, windsurf, copilot)
|
|
131
|
+
scopecairn kiro install # steering .kiro/steering + knowledge skill .kiro/skills/scopecairn-knowledge
|
|
140
132
|
scopecairn opencode install # /scopecairn slash command + permission snippet
|
|
141
133
|
scopecairn claude uninstall # clean removal, user content preserved
|
|
142
134
|
scopecairn agents list
|
|
143
135
|
```
|
|
144
136
|
|
|
145
|
-
## Commands
|
|
137
|
+
## 6 Core Commands
|
|
138
|
+
|
|
139
|
+
ScopeCairn organizes its capabilities into **6 core commands** aligned with codebase architecture:
|
|
140
|
+
|
|
141
|
+
| Core Command | Description & Key Options |
|
|
142
|
+
| ------------ | ------------------------- |
|
|
143
|
+
| `scan` | **Index & Database Lifecycle:** Index repository. Options: `--rebuild` (rebuild from scratch), `--clean` (remove `.scopecairn/`), `--watch` (auto-refresh daemon on file save). |
|
|
144
|
+
| `context "<task>"` | **AI Agent Context & Scope:** Main agent entry point with auto-refresh, task classification, relevant context, task scope, and Task Guard. Options: `--escalate`, `--mode NORMAL\|FAST\|SAFE\|AUDIT`, `--max-tokens N`, `--no-history`, `--with-diff`, `--json`. |
|
|
145
|
+
| `impact <target>` | **Blast Radius & Test Selection:** Change impact analysis across direct, indirect, tests, UI components, and API clients. Options: `--run-tests` (run affected tests directly), `--tests` (list affected tests only), `--repos [list]` (cross-repo blast radius). |
|
|
146
|
+
| `graph [symbol]` | **Knowledge Graph & Navigation:** Symbol relations, PageRank centrality, community clusters, and cycle status. Options: `--to <target>` (shortest multi-hop call path), `--export <fmt>` (`mermaid\|graphml\|dot\|json`). |
|
|
147
|
+
| `verify` | **Pre-Commit Safety Guard:** Working tree check against protected files, task scope creep, and circular dependency regressions. Options: `--diff` (structural symbol-level diff vs HEAD), `--arch` (architectural layer rules), `--strict`, `--task <t>`. |
|
|
148
|
+
| `status` | **Health & Diagnostics:** Index statistics and metadata. Options: `--doctor` (integration and adapter health check), `--verbose`, `--html` (generate local HTML dashboard). |
|
|
149
|
+
|
|
150
|
+
### Helper & Setup Commands
|
|
146
151
|
|
|
147
152
|
| Command | Description |
|
|
148
|
-
|
|
149
|
-
| `init [path]` | Initial indexing + Skill, Workflow, detected agent skills (`--agents …\|all
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
| `test-select <target> [--run]` | List affected test files via graph dependency traversal & runner detection (`--run` executes runner) |
|
|
161
|
-
| `verify [--task <t>] [--strict]` | Post-edit guard: check modified protected files, task scope creep, and circular dependency regressions |
|
|
162
|
-
| `diff` | Structural symbol-level diff of working tree vs HEAD with breaking impact alerts |
|
|
163
|
-
| `arch-check [--init]` | Check architectural layer rules & disallowed import boundaries (`--init` scaffolds `.scopecairn/arch.json`) |
|
|
164
|
-
| `watch [--debounce <ms>]` | Daemon mode: auto-refresh index in background on file saves |
|
|
165
|
-
| `doctor --verbose` | Also surface adapter/graph errors from `.scopecairn/last-run.log` to stderr |
|
|
166
|
-
| `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
|
|
167
|
-
| `update [--check]` | Update ScopeCairn to the latest npm release (`--check` only checks) |
|
|
168
|
-
|
|
169
|
-
Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`, `verify`, `diff`, `test-select`, `arch-check`)
|
|
170
|
-
are read-only toward source and only write to `.scopecairn/` — safe to
|
|
171
|
-
allowlist. `init`, `scan`, `rebuild`, and `watch` are manual/user-side commands.
|
|
153
|
+
| ------- | ----------- |
|
|
154
|
+
| `init [path]` | Initial indexing + Skill, Workflow, detected agent skills (`--agents …\|all`). |
|
|
155
|
+
| `read symbol <name>` | Symbol-level source excerpt (not whole files). |
|
|
156
|
+
| `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight tuning (dev-only, hidden from `--help`). |
|
|
157
|
+
| `<agent> install` | Agent installers: `antigravity`, `claude`, `cursor`, `windsurf`, `copilot`, `kiro`, `opencode`. |
|
|
158
|
+
|
|
159
|
+
> [!NOTE]
|
|
160
|
+
> **Backward Compatibility:** All previous standalone commands (`rebuild`, `clean`, `watch`, `doctor`, `dashboard`, `test-select`, `path`, `export`, `diff`, `arch-check`, `history`) are preserved as hidden aliases mapped directly into these 6 core commands — they still work but no longer appear in `--help`. `update` is deprecated (use `npm install -g scopecairn@latest`).
|
|
161
|
+
|
|
162
|
+
Agent commands (`context`, `impact`, `read`, `graph`, `status`, `verify`)
|
|
163
|
+
are read-only toward source code and safe to allowlist. `init` and `scan`
|
|
164
|
+
are user-side setup/lifecycle commands.
|
|
172
165
|
|
|
173
166
|
## Framework adapters
|
|
174
167
|
|
|
175
168
|
Detected automatically during `scan` (shown in output and `status`).
|
|
176
169
|
No configuration needed.
|
|
177
170
|
|
|
178
|
-
| Adapter
|
|
179
|
-
|
|
180
|
-
| `nextjs`
|
|
181
|
-
| `prisma`
|
|
182
|
-
| `drizzle`
|
|
183
|
-
| `express`
|
|
184
|
-
| `fastapi`
|
|
185
|
-
| `nestjs`
|
|
186
|
-
| `django`
|
|
187
|
-
| `sqlalchemy` | requirements/pyproject
|
|
188
|
-
| `vue`
|
|
171
|
+
| Adapter | Detects | Produces |
|
|
172
|
+
| ------------ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
173
|
+
| `nextjs` | `app/`, `pages/`, `next.config.*` | Route symbols (`GET /api/x`, `PAGE /y` incl. route groups, dynamic segments), `ROUTES_TO` handler edges, `QUERIES` handler→model edges |
|
|
174
|
+
| `prisma` | `schema.prisma` | `model` symbols for `QUERIES` resolution |
|
|
175
|
+
| `drizzle` | `drizzle*` paths/config, `db/` schemas | `model` symbols from `pgTable`/`sqliteTable`/`mysqlTable`; `QUERIES` from `db.query.*` relational and `db.select/insert/update/delete` builder calls |
|
|
176
|
+
| `express` | `package.json` deps | Route symbols from `app/router.METHOD(path)`, handler edges |
|
|
177
|
+
| `fastapi` | `.py` routes, requirements/pyproject | Route symbols from `@app.METHOD("/path")` decorators |
|
|
178
|
+
| `nestjs` | `*.controller.ts`, `*.service.ts`, `nest-cli.json` | Route symbols from `@Controller` and `@Get/@Post/...` decorators, `ROUTES_TO` handler edges, `@Injectable()` service providers |
|
|
179
|
+
| `django` | `manage.py`, `urls.py`, `models.py` | Route symbols from `path()`/`re_path()` in `urls.py`, `ROUTES_TO` handler edges, `model` symbols from `models.Model` |
|
|
180
|
+
| `sqlalchemy` | requirements/pyproject | `model` symbols from `class X(Base)`/`__tablename__`; `QUERIES` from `session.query/select` |
|
|
181
|
+
| `vue` | `.vue` SFCs, `package.json` deps | `component` symbols per SFC; `IMPORTS` edges from used components in template |
|
|
189
182
|
|
|
190
183
|
Limits (honest): non-Prisma/Drizzle/SQLAlchemy ORMs, inter-table
|
|
191
184
|
references, raw SQL strings, and fully dynamic table names are not mapped
|
|
@@ -205,6 +198,16 @@ prompt → scopecairn context (refresh at start) → agent edits code
|
|
|
205
198
|
|
|
206
199
|
Measure retrieval quality anytime: `scopecairn benchmark [--tune]`.
|
|
207
200
|
|
|
201
|
+
## History privacy
|
|
202
|
+
|
|
203
|
+
`scan` mengindeks pesan commit git lokal ke tabel `commits`/`commit_files` di
|
|
204
|
+
`.scopecairn/scopecairn.db` (dilewati commit >40 file, lockfile, dan generated).
|
|
205
|
+
Isi commit **tidak** masuk `GRAPH.md`/`GRAPH.html`/`graph --export` (sering di-commit dan
|
|
206
|
+
dibagikan) — riwayat hanya hidup di database lokal dan output `context`.
|
|
207
|
+
Pesan commit bisa memuat info sensitif; karena tidak keluar dari mesin, risikonya
|
|
208
|
+
kecil. Repo tanpa git atau clone dangkal tetap didukung (riwayat parsial/absen,
|
|
209
|
+
dilaporkan `status --doctor`).
|
|
210
|
+
|
|
208
211
|
## License
|
|
209
212
|
|
|
210
213
|
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
logError
|
|
4
|
+
} from "./chunk-OPA4TMAT.js";
|
|
5
|
+
|
|
6
|
+
// src/retrieval/tokenize.ts
|
|
7
|
+
var STOP = /* @__PURE__ */ new Set([
|
|
8
|
+
"the",
|
|
9
|
+
"a",
|
|
10
|
+
"an",
|
|
11
|
+
"of",
|
|
12
|
+
"to",
|
|
13
|
+
"in",
|
|
14
|
+
"for",
|
|
15
|
+
"on",
|
|
16
|
+
"with",
|
|
17
|
+
"and",
|
|
18
|
+
"or",
|
|
19
|
+
"src",
|
|
20
|
+
"lib",
|
|
21
|
+
"app",
|
|
22
|
+
"test",
|
|
23
|
+
"tests",
|
|
24
|
+
"spec"
|
|
25
|
+
]);
|
|
26
|
+
function stemVariants(t) {
|
|
27
|
+
const out = /* @__PURE__ */ new Set([t]);
|
|
28
|
+
if (t.endsWith("ies") && t.length > 4) out.add(t.slice(0, -3) + "y");
|
|
29
|
+
else if (t.endsWith("es") && t.length > 4 && /(s|x|z|ch|sh)es$/.test(t)) out.add(t.slice(0, -2));
|
|
30
|
+
else if (t.endsWith("s") && t.length > 3 && !t.endsWith("ss")) out.add(t.slice(0, -1));
|
|
31
|
+
if (t.endsWith("ing") && t.length > 5) {
|
|
32
|
+
const base = t.slice(0, -3);
|
|
33
|
+
out.add(base);
|
|
34
|
+
out.add(base + "e");
|
|
35
|
+
}
|
|
36
|
+
if (t.endsWith("ation") && t.length > 7) {
|
|
37
|
+
const base = t.slice(0, -5);
|
|
38
|
+
out.add(base);
|
|
39
|
+
}
|
|
40
|
+
return [...out];
|
|
41
|
+
}
|
|
42
|
+
var EXPLICIT_STEM = /* @__PURE__ */ new Map([
|
|
43
|
+
["configuration", "config"],
|
|
44
|
+
["configurations", "config"],
|
|
45
|
+
["configured", "config"],
|
|
46
|
+
["ranking", "rank"],
|
|
47
|
+
["ranked", "rank"],
|
|
48
|
+
["weights", "weight"],
|
|
49
|
+
["dependencies", "dependency"],
|
|
50
|
+
["dependency", "depend"],
|
|
51
|
+
["extraction", "extract"],
|
|
52
|
+
["extracted", "extract"]
|
|
53
|
+
]);
|
|
54
|
+
function tokenizeIdentifier(raw) {
|
|
55
|
+
const parts = raw.replace(/[\\/]/g, " ").replace(/[.:\-_"']/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/g).filter((t) => t.length >= 2 && !STOP.has(t));
|
|
56
|
+
const out = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const t of parts) {
|
|
58
|
+
out.add(t);
|
|
59
|
+
for (const v of stemVariants(t)) {
|
|
60
|
+
if (v.length >= 2 && !STOP.has(v)) out.add(v);
|
|
61
|
+
}
|
|
62
|
+
const e = EXPLICIT_STEM.get(t);
|
|
63
|
+
if (e && !STOP.has(e)) out.add(e);
|
|
64
|
+
}
|
|
65
|
+
return [...out];
|
|
66
|
+
}
|
|
67
|
+
function tokenizeTask(text) {
|
|
68
|
+
return tokenizeIdentifier(text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/retrieval/history/index.ts
|
|
72
|
+
function stripConventionalPrefix(subject) {
|
|
73
|
+
return subject.replace(/^[a-z]+(\([^)]*\))?(!)?:\s*/i, "").replace(/^\[\w+\]\s*/, "").trim();
|
|
74
|
+
}
|
|
75
|
+
function isPoorSubject(subject) {
|
|
76
|
+
const clean = stripConventionalPrefix(subject);
|
|
77
|
+
if (/^merge\s+(pr|pull request)\s*#?\d*/i.test(clean)) return true;
|
|
78
|
+
if (/^(update|updates|wip|tmp|test|fix|patch|changes?)$/i.test(clean)) return true;
|
|
79
|
+
return tokenizeIdentifier(clean).length < 2;
|
|
80
|
+
}
|
|
81
|
+
function commitTokens(subject, body, paths) {
|
|
82
|
+
const out = /* @__PURE__ */ new Set();
|
|
83
|
+
const cleanSubject = stripConventionalPrefix(subject);
|
|
84
|
+
for (const t of tokenizeIdentifier(cleanSubject)) out.add(t);
|
|
85
|
+
if (!isPoorSubject(subject)) {
|
|
86
|
+
for (const t of tokenizeIdentifier(body.slice(0, 2e3))) out.add(t);
|
|
87
|
+
}
|
|
88
|
+
for (const p of paths) {
|
|
89
|
+
for (const t of tokenizeIdentifier(p)) out.add(t);
|
|
90
|
+
}
|
|
91
|
+
return [...out];
|
|
92
|
+
}
|
|
93
|
+
function syncCommitFts(db, shas) {
|
|
94
|
+
let n = 0;
|
|
95
|
+
try {
|
|
96
|
+
const del = db.prepare(`DELETE FROM commit_fts WHERE sha = ?`);
|
|
97
|
+
const ins = db.prepare(`INSERT INTO commit_fts(sha, subject, body, paths) VALUES (?, ?, ?, ?)`);
|
|
98
|
+
const getCommit = db.prepare(`SELECT subject, body FROM commits WHERE sha = ?`);
|
|
99
|
+
const getPaths = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
|
|
100
|
+
for (const sha of shas) {
|
|
101
|
+
const c = getCommit.get(sha);
|
|
102
|
+
if (!c) continue;
|
|
103
|
+
const paths = getPaths.all(sha).map((r) => r.path);
|
|
104
|
+
del.run(sha);
|
|
105
|
+
ins.run(sha, c.subject, c.body.slice(0, 4e3), paths.join(" "));
|
|
106
|
+
n++;
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logError("history", err);
|
|
110
|
+
}
|
|
111
|
+
return n;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/retrieval/history/search.ts
|
|
115
|
+
var HISTORY_HALF_LIFE_DAYS = 180;
|
|
116
|
+
var HISTORY_MIN_SCORE = 0.05;
|
|
117
|
+
var HISTORY_MAX_FILES = 40;
|
|
118
|
+
var BM25_LIMIT = 20;
|
|
119
|
+
function recencyScore(ts, nowSec, halfLifeDays) {
|
|
120
|
+
if (ts <= 0) return 0.5;
|
|
121
|
+
const ageDays = Math.max(0, (nowSec - ts) / 86400);
|
|
122
|
+
return Math.pow(0.5, ageDays / Math.max(1, halfLifeDays));
|
|
123
|
+
}
|
|
124
|
+
function sizePenalty(nFiles) {
|
|
125
|
+
return 1 / Math.log(2 + Math.max(0, nFiles));
|
|
126
|
+
}
|
|
127
|
+
function jaccard(a, b) {
|
|
128
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
129
|
+
let inter = 0;
|
|
130
|
+
for (const x of a) if (b.has(x)) inter++;
|
|
131
|
+
const union = a.size + b.size - inter;
|
|
132
|
+
return union === 0 ? 0 : inter / union;
|
|
133
|
+
}
|
|
134
|
+
function resolveCurrentPath(db, path) {
|
|
135
|
+
let cur = path;
|
|
136
|
+
try {
|
|
137
|
+
const stmt = db.prepare(`SELECT path FROM commit_files WHERE old_path = ? ORDER BY rowid DESC LIMIT 1`);
|
|
138
|
+
for (let i = 0; i < 5; i++) {
|
|
139
|
+
const row = stmt.get(cur);
|
|
140
|
+
if (!row || row.path === cur) break;
|
|
141
|
+
cur = row.path;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
return cur;
|
|
146
|
+
}
|
|
147
|
+
function candidates(db, tokens, asOfTs, excludeShas) {
|
|
148
|
+
const excluded = new Set(excludeShas ?? []);
|
|
149
|
+
if (tokens.length === 0) return [];
|
|
150
|
+
const q = tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
|
|
151
|
+
try {
|
|
152
|
+
const rows = db.prepare(
|
|
153
|
+
`SELECT sha, bm25(commit_fts) AS rank FROM commit_fts
|
|
154
|
+
WHERE commit_fts MATCH ? ORDER BY rank LIMIT ${BM25_LIMIT}`
|
|
155
|
+
).all(q);
|
|
156
|
+
if (rows.length === 0) return [];
|
|
157
|
+
const filt = rows.filter((r) => !excluded.has(r.sha));
|
|
158
|
+
if (filt.length === 0) return [];
|
|
159
|
+
const worst = Math.min(...filt.map((r) => r.rank));
|
|
160
|
+
const range = Math.max(1e-9, 0 - worst + 1);
|
|
161
|
+
void asOfTs;
|
|
162
|
+
return filt.map((r) => ({
|
|
163
|
+
sha: r.sha,
|
|
164
|
+
bm25norm: Math.max(0, Math.min(1, (0 - r.rank + 0.5) / range))
|
|
165
|
+
}));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logError("history", err);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
let rows = db.prepare(`SELECT sha, subject, ts FROM commits`).all();
|
|
171
|
+
if (asOfTs !== void 0) rows = rows.filter((r) => r.ts <= asOfTs);
|
|
172
|
+
rows = rows.filter((r) => !excluded.has(r.sha));
|
|
173
|
+
const tset = new Set(tokens);
|
|
174
|
+
const scored = rows.map((r) => {
|
|
175
|
+
let paths = [];
|
|
176
|
+
try {
|
|
177
|
+
paths = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`).all(r.sha).map((x) => x.path);
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
const doc = new Set(commitTokens(r.subject, "", paths));
|
|
181
|
+
let hit = 0;
|
|
182
|
+
for (const t of tset) if (doc.has(t)) hit++;
|
|
183
|
+
return { sha: r.sha, bm25norm: tset.size === 0 ? 0 : hit / tset.size };
|
|
184
|
+
}).filter((r) => r.bm25norm > 0);
|
|
185
|
+
scored.sort((a, b) => b.bm25norm - a.bm25norm || (a.sha < b.sha ? -1 : 1));
|
|
186
|
+
return scored.slice(0, BM25_LIMIT);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logError("history", err);
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function historySearch(db, task, opts = {}) {
|
|
193
|
+
const seedFiles = new Set(opts.seedFiles ?? []);
|
|
194
|
+
const limit = Math.max(1, Math.min(10, opts.limit ?? 3));
|
|
195
|
+
const halfLife = opts.halfLifeDays ?? HISTORY_HALF_LIFE_DAYS;
|
|
196
|
+
const minScore = opts.minScore ?? HISTORY_MIN_SCORE;
|
|
197
|
+
const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1e3);
|
|
198
|
+
const tokens = tokenizeTask(task);
|
|
199
|
+
if (tokens.length === 0) return [];
|
|
200
|
+
let cands;
|
|
201
|
+
try {
|
|
202
|
+
cands = candidates(db, tokens, opts.asOfTs, opts.excludeShas);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
logError("history", err);
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
if (cands.length === 0) return [];
|
|
208
|
+
const out = [];
|
|
209
|
+
try {
|
|
210
|
+
const getCommit = db.prepare(`SELECT ts, subject FROM commits WHERE sha = ?`);
|
|
211
|
+
const getFiles = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
|
|
212
|
+
const fileExists = db.prepare(`SELECT 1 AS ok FROM files WHERE path = ? LIMIT 1`);
|
|
213
|
+
for (const c of cands) {
|
|
214
|
+
const commit = getCommit.get(c.sha);
|
|
215
|
+
if (!commit) continue;
|
|
216
|
+
if (opts.asOfTs !== void 0 && commit.ts > opts.asOfTs) continue;
|
|
217
|
+
const rawFiles = getFiles.all(c.sha).map((r) => r.path);
|
|
218
|
+
if (rawFiles.length > HISTORY_MAX_FILES) continue;
|
|
219
|
+
const live = [];
|
|
220
|
+
for (const p of rawFiles) {
|
|
221
|
+
const cur = resolveCurrentPath(db, p);
|
|
222
|
+
try {
|
|
223
|
+
const hit = fileExists.get(cur);
|
|
224
|
+
if (hit) {
|
|
225
|
+
if (!live.includes(cur)) live.push(cur);
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (live.length === 0) continue;
|
|
231
|
+
const rec = recencyScore(commit.ts, nowSec, halfLife);
|
|
232
|
+
const pen = sizePenalty(rawFiles.length);
|
|
233
|
+
const ov = jaccard(new Set(live), seedFiles);
|
|
234
|
+
const score = c.bm25norm * rec * pen * (1 + ov);
|
|
235
|
+
if (score < minScore) continue;
|
|
236
|
+
const d = new Date(commit.ts * 1e3);
|
|
237
|
+
out.push({
|
|
238
|
+
sha: c.sha,
|
|
239
|
+
shortSha: c.sha.slice(0, 7),
|
|
240
|
+
ts: commit.ts,
|
|
241
|
+
date: Number.isNaN(d.getTime()) ? "-" : d.toISOString().slice(0, 10),
|
|
242
|
+
subject: commit.subject,
|
|
243
|
+
files: [...live].sort(),
|
|
244
|
+
score,
|
|
245
|
+
sortTs: commit.ts
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logError("history", err);
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
out.sort((a, b) => b.score - a.score || b.sortTs - a.sortTs || (a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0));
|
|
253
|
+
return out.slice(0, limit).map(({ sortTs: _s, ...rest }) => rest);
|
|
254
|
+
}
|
|
255
|
+
function coChangeSet(analogs) {
|
|
256
|
+
const counts = /* @__PURE__ */ new Map();
|
|
257
|
+
for (const a of analogs) {
|
|
258
|
+
for (const f of new Set(a.files)) counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
return [...counts.entries()].filter(([, n]) => n >= 2).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || (a.path < b.path ? -1 : 1));
|
|
261
|
+
}
|
|
262
|
+
function groupHistoryFiles(files) {
|
|
263
|
+
const source = [];
|
|
264
|
+
const test = [];
|
|
265
|
+
const config = [];
|
|
266
|
+
const docs = [];
|
|
267
|
+
for (const f of files) {
|
|
268
|
+
const base = f.split("/").pop() ?? f;
|
|
269
|
+
const lower = f.toLowerCase();
|
|
270
|
+
if (f.includes(".test.") || f.includes(".spec.") || f.includes("__tests__") || base.startsWith("test_") || base.endsWith("_test.py") || lower.startsWith("tests/")) {
|
|
271
|
+
test.push(f);
|
|
272
|
+
} else if (/\.(json|ya?ml|toml|ini|cfg)$/.test(lower) || ["package.json", "dockerfile", "makefile"].includes(base.toLowerCase())) {
|
|
273
|
+
config.push(f);
|
|
274
|
+
} else if (/\.mdx?$/.test(lower) || lower.startsWith("docs/")) {
|
|
275
|
+
docs.push(f);
|
|
276
|
+
} else {
|
|
277
|
+
source.push(f);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { source, test, config, docs };
|
|
281
|
+
}
|
|
282
|
+
function leaveOneOut(db, shas, opts = {}) {
|
|
283
|
+
const out = [];
|
|
284
|
+
try {
|
|
285
|
+
const getCommit = db.prepare(`SELECT ts, subject FROM commits WHERE sha = ?`);
|
|
286
|
+
const getFiles = db.prepare(`SELECT path FROM commit_files WHERE sha = ?`);
|
|
287
|
+
for (const sha of shas) {
|
|
288
|
+
const commit = getCommit.get(sha);
|
|
289
|
+
if (!commit) continue;
|
|
290
|
+
const expected = getFiles.all(sha).map((r) => r.path);
|
|
291
|
+
const seeds = opts.seedFilesOf ? opts.seedFilesOf(sha) : [];
|
|
292
|
+
const analogs = historySearch(db, commit.subject, {
|
|
293
|
+
seedFiles: seeds,
|
|
294
|
+
limit: opts.limit ?? 3,
|
|
295
|
+
asOfTs: commit.ts - 1,
|
|
296
|
+
excludeShas: [sha],
|
|
297
|
+
nowSec: opts.nowSec
|
|
298
|
+
});
|
|
299
|
+
const recalledFiles = /* @__PURE__ */ new Set();
|
|
300
|
+
for (const a of analogs) for (const f of a.files) recalledFiles.add(f);
|
|
301
|
+
for (const c of coChangeSet(analogs)) recalledFiles.add(c.path);
|
|
302
|
+
const recalled = expected.filter((f) => recalledFiles.has(f));
|
|
303
|
+
out.push({ sha, subject: commit.subject, recalled, expected, recall: expected.length === 0 ? 1 : recalled.length / expected.length });
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
logError("history", err);
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export {
|
|
312
|
+
tokenizeIdentifier,
|
|
313
|
+
tokenizeTask,
|
|
314
|
+
syncCommitFts,
|
|
315
|
+
HISTORY_HALF_LIFE_DAYS,
|
|
316
|
+
HISTORY_MIN_SCORE,
|
|
317
|
+
HISTORY_MAX_FILES,
|
|
318
|
+
recencyScore,
|
|
319
|
+
sizePenalty,
|
|
320
|
+
jaccard,
|
|
321
|
+
resolveCurrentPath,
|
|
322
|
+
historySearch,
|
|
323
|
+
coChangeSet,
|
|
324
|
+
groupHistoryFiles,
|
|
325
|
+
leaveOneOut
|
|
326
|
+
};
|