scopecairn 0.8.0 → 0.9.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 CHANGED
@@ -36,7 +36,7 @@ No MCP server, no embeddings, no network calls, no telemetry.
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),
39
- Prisma models, and Drizzle tables/queries detected automatically.
39
+ Prisma models, Drizzle tables/queries, Express, FastAPI, NestJS (controllers, routes, injectables), Django (urls, models), SQLAlchemy, and Vue SFC detected automatically.
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).
@@ -157,14 +157,18 @@ scopecairn agents list
157
157
  | `read symbol <name>` | Symbol-level source excerpt (not whole files) |
158
158
  | `<agent> install` | Per-agent setup: `antigravity`, `claude`, `gemini`, `cursor`, `windsurf`, `copilot`, `kiro` (each also `uninstall`; `agents list` to see all) |
159
159
  | `dashboard` | Generate local HTML dashboard at `.scopecairn/dashboard.html` |
160
- | `test-select <target>` | List test files affected by a file/symbol change target |
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 |
161
165
  | `doctor --verbose` | Also surface adapter/graph errors from `.scopecairn/last-run.log` to stderr |
162
166
  | `benchmark [--tune]` | Retrieval recall, irrelevant ratio, context reduction + weight calibration |
163
167
  | `update [--check]` | Update ScopeCairn to the latest npm release (`--check` only checks) |
164
168
 
165
- Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`)
169
+ Agent commands (`context`, `impact`, `read`, `graph`, `status`, `doctor`, `verify`, `diff`, `test-select`, `arch-check`)
166
170
  are read-only toward source and only write to `.scopecairn/` — safe to
167
- allowlist. `init`, `scan`, and `rebuild` are manual/user-side commands.
171
+ allowlist. `init`, `scan`, `rebuild`, and `watch` are manual/user-side commands.
168
172
 
169
173
  ## Framework adapters
170
174
 
@@ -178,6 +182,8 @@ No configuration needed.
178
182
  | `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 |
179
183
  | `express` | `package.json` deps | Route symbols from `app/router.METHOD(path)`, handler edges |
180
184
  | `fastapi` | `.py` routes, requirements/pyproject | Route symbols from `@app.METHOD("/path")` decorators |
185
+ | `nestjs` | `*.controller.ts`, `*.service.ts`, `nest-cli.json` | Route symbols from `@Controller` and `@Get/@Post/...` decorators, `ROUTES_TO` handler edges, `@Injectable()` service providers |
186
+ | `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` |
181
187
  | `sqlalchemy` | requirements/pyproject | `model` symbols from `class X(Base)`/`__tablename__`; `QUERIES` from `session.query/select` |
182
188
  | `vue` | `.vue` SFCs, `package.json` deps | `component` symbols per SFC; `IMPORTS` edges from used components in template |
183
189
 
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/scope/protected.ts
4
+ import fs2 from "fs";
5
+ import path2 from "path";
6
+
7
+ // src/db.ts
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import crypto from "crypto";
11
+ import { createRequire } from "module";
12
+ function loadDatabaseSync() {
13
+ const req = createRequire(import.meta.url);
14
+ const spec = "node:sqlite";
15
+ const m = req(spec);
16
+ return m.DatabaseSync;
17
+ }
18
+ var DATA_DIR_NAME = ".scopecairn";
19
+ var DB_FILE_NAME = "scopecairn.db";
20
+ var SCHEMA_VERSION = 6;
21
+ function dataDir(repoRoot) {
22
+ return path.join(repoRoot, DATA_DIR_NAME);
23
+ }
24
+ function dbPath(repoRoot) {
25
+ return path.join(dataDir(repoRoot), DB_FILE_NAME);
26
+ }
27
+ function openDb(repoRoot) {
28
+ fs.mkdirSync(dataDir(repoRoot), { recursive: true });
29
+ const DatabaseSync = loadDatabaseSync();
30
+ const db = new DatabaseSync(dbPath(repoRoot));
31
+ db.exec(`
32
+ PRAGMA journal_mode = WAL;
33
+ PRAGMA foreign_keys = ON;
34
+ CREATE TABLE IF NOT EXISTS files (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ path TEXT NOT NULL UNIQUE,
37
+ language TEXT NOT NULL,
38
+ hash TEXT NOT NULL,
39
+ size INTEGER NOT NULL,
40
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
41
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
42
+ );
43
+ CREATE TABLE IF NOT EXISTS symbols (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
46
+ name TEXT NOT NULL,
47
+ type TEXT NOT NULL,
48
+ signature TEXT NOT NULL DEFAULT '',
49
+ start_line INTEGER NOT NULL DEFAULT 1,
50
+ end_line INTEGER NOT NULL DEFAULT 1
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
53
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
54
+ CREATE INDEX IF NOT EXISTS idx_symbols_type ON symbols(type);
55
+ CREATE TABLE IF NOT EXISTS relationships (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ source_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
58
+ target_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
59
+ relationship_type TEXT NOT NULL,
60
+ weight REAL NOT NULL DEFAULT 0.5,
61
+ confidence REAL NOT NULL DEFAULT 1.0
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_id);
64
+ CREATE INDEX IF NOT EXISTS idx_rel_target ON relationships(target_id);
65
+ CREATE INDEX IF NOT EXISTS idx_rel_type ON relationships(relationship_type);
66
+ CREATE TABLE IF NOT EXISTS meta (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT NOT NULL
69
+ );
70
+ -- Fase 3: FTS5 symbol index (token match, bukan embedding).
71
+ CREATE VIRTUAL TABLE IF NOT EXISTS symbol_index USING fts5(
72
+ symbol_id UNINDEXED, tokens
73
+ );
74
+ CREATE TABLE IF NOT EXISTS tasks (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ description TEXT NOT NULL,
77
+ complexity TEXT NOT NULL DEFAULT '',
78
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
79
+ completed_at TEXT
80
+ );
81
+ CREATE TABLE IF NOT EXISTS task_context (
82
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
83
+ symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
84
+ score REAL NOT NULL DEFAULT 0,
85
+ reason TEXT NOT NULL DEFAULT ''
86
+ );
87
+ CREATE INDEX IF NOT EXISTS idx_task_context_task ON task_context(task_id);
88
+ -- Fase 5: invocation log untuk compliance rate (AI-5, \xA718).
89
+ CREATE TABLE IF NOT EXISTS invocations (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
92
+ command TEXT NOT NULL,
93
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
94
+ );
95
+ CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command);
96
+ -- Graphify Engine: node centrality, community clustering, cycle cache.
97
+ CREATE TABLE IF NOT EXISTS node_metrics (
98
+ node_id INTEGER PRIMARY KEY,
99
+ pagerank REAL NOT NULL DEFAULT 0.0,
100
+ betweenness REAL NOT NULL DEFAULT 0.0,
101
+ in_degree INTEGER NOT NULL DEFAULT 0,
102
+ out_degree INTEGER NOT NULL DEFAULT 0,
103
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
104
+ FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
105
+ );
106
+ CREATE TABLE IF NOT EXISTS clusters (
107
+ node_id INTEGER PRIMARY KEY,
108
+ cluster_id INTEGER NOT NULL,
109
+ cluster_name TEXT,
110
+ modularity REAL,
111
+ FOREIGN KEY(node_id) REFERENCES symbols(id) ON DELETE CASCADE
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_clusters_cid ON clusters(cluster_id);
114
+ CREATE TABLE IF NOT EXISTS cycles (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ cycle_hash TEXT UNIQUE,
117
+ length INTEGER NOT NULL DEFAULT 0,
118
+ nodes_json TEXT NOT NULL DEFAULT '[]',
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
+ );
121
+ `);
122
+ db.prepare(
123
+ `INSERT INTO meta(key, value) VALUES ('schema_version', ?)
124
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
125
+ ).run(String(SCHEMA_VERSION));
126
+ for (const [table, column, ddl] of [
127
+ ["relationships", "evidence", "evidence TEXT NOT NULL DEFAULT 'EXTRACTED'"],
128
+ ["symbols", "doc", "doc TEXT NOT NULL DEFAULT ''"]
129
+ ]) {
130
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
131
+ if (!cols.some((c) => c.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
132
+ }
133
+ db.exec(
134
+ `UPDATE relationships SET evidence = 'INFERRED'
135
+ WHERE relationship_type IN ('QUERIES','ROUTES_TO','TESTS') AND evidence = 'EXTRACTED'`
136
+ );
137
+ db.exec(
138
+ `UPDATE relationships SET evidence = 'AMBIGUOUS'
139
+ WHERE relationship_type = 'CALLS' AND confidence < 1.0 AND evidence = 'EXTRACTED'`
140
+ );
141
+ return db;
142
+ }
143
+ function hashContent(buf) {
144
+ return crypto.createHash("sha256").update(buf).digest("hex");
145
+ }
146
+
147
+ // src/scope/protected.ts
148
+ var DEFAULT_PROTECTED_PATTERNS = [
149
+ "migrations/**",
150
+ "prisma/**",
151
+ "schema.prisma",
152
+ "*.sql",
153
+ "alembic/**",
154
+ ".env*",
155
+ "package.json",
156
+ "package-lock.json",
157
+ "pnpm-lock.yaml",
158
+ "yarn.lock",
159
+ "bun.lockb"
160
+ ];
161
+ function globToRegExp(pattern) {
162
+ let re = "";
163
+ let i = 0;
164
+ while (i < pattern.length) {
165
+ const c = pattern[i];
166
+ if (c === "*") {
167
+ if (pattern[i + 1] === "*") {
168
+ if (pattern[i + 2] === "/") {
169
+ re += "(.*/)?";
170
+ i += 3;
171
+ } else {
172
+ re += ".*";
173
+ i += 2;
174
+ }
175
+ } else {
176
+ re += "[^/]*";
177
+ i++;
178
+ }
179
+ } else if (c === "?") {
180
+ re += "[^/]";
181
+ i++;
182
+ } else {
183
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
184
+ i++;
185
+ }
186
+ }
187
+ return new RegExp(`^${re}$`);
188
+ }
189
+ function matchPattern(pattern, posixPath) {
190
+ const base = posixPath.split("/").pop() ?? posixPath;
191
+ if (pattern.endsWith("/")) {
192
+ const dir = pattern.slice(0, -1);
193
+ return posixPath === dir || posixPath.startsWith(dir + "/");
194
+ }
195
+ if (!pattern.includes("/")) {
196
+ return globToRegExp(pattern).test(base);
197
+ }
198
+ return globToRegExp(pattern).test(posixPath);
199
+ }
200
+ function loadProtectedPatterns(repoRoot) {
201
+ const p = path2.join(dataDir(repoRoot), "protected.yml");
202
+ if (!fs2.existsSync(p)) return [...DEFAULT_PROTECTED_PATTERNS];
203
+ try {
204
+ const patterns = [];
205
+ for (const line of fs2.readFileSync(p, "utf8").split("\n")) {
206
+ const t = line.trim();
207
+ if (!t || t.startsWith("#")) continue;
208
+ patterns.push(t);
209
+ }
210
+ return patterns.length > 0 ? patterns : [...DEFAULT_PROTECTED_PATTERNS];
211
+ } catch {
212
+ return [...DEFAULT_PROTECTED_PATTERNS];
213
+ }
214
+ }
215
+ function isProtected(posixPath, patterns = DEFAULT_PROTECTED_PATTERNS) {
216
+ let hit = false;
217
+ for (const p of patterns) {
218
+ if (p.startsWith("!")) {
219
+ if (matchPattern(p.slice(1), posixPath)) hit = false;
220
+ } else if (matchPattern(p, posixPath)) {
221
+ hit = true;
222
+ }
223
+ }
224
+ return hit;
225
+ }
226
+ var SECRET_PATTERNS = [
227
+ ".env",
228
+ ".env.*",
229
+ "*.pem",
230
+ "*.key",
231
+ "credentials.*",
232
+ "secrets.*"
233
+ ];
234
+ function isSecretFile(posixPath) {
235
+ return SECRET_PATTERNS.some((p) => matchPattern(p, posixPath));
236
+ }
237
+ var META_EXTS = /* @__PURE__ */ new Set([
238
+ ".json",
239
+ ".yaml",
240
+ ".yml",
241
+ ".toml",
242
+ ".sql",
243
+ ".prisma",
244
+ ".cfg",
245
+ ".ini"
246
+ ]);
247
+ var META_BASENAMES = /* @__PURE__ */ new Set([
248
+ "dockerfile",
249
+ "makefile",
250
+ "package.json"
251
+ ]);
252
+ function isIndexableMetaFile(posixPath, patterns) {
253
+ if (isSecretFile(posixPath)) return false;
254
+ const base = (posixPath.split("/").pop() ?? "").toLowerCase();
255
+ if (META_BASENAMES.has(base)) return true;
256
+ const dot = base.lastIndexOf(".");
257
+ if (dot !== -1 && META_EXTS.has(base.slice(dot))) return true;
258
+ if (patterns ? isProtected(posixPath, patterns) : isProtected(posixPath)) return true;
259
+ return false;
260
+ }
261
+ function ensureProtectedFile(repoRoot) {
262
+ const dir = dataDir(repoRoot);
263
+ fs2.mkdirSync(dir, { recursive: true });
264
+ const p = path2.join(dir, "protected.yml");
265
+ if (!fs2.existsSync(p)) {
266
+ fs2.writeFileSync(
267
+ p,
268
+ `# ScopeCairn Protected patterns (FR-07). Satu pola per baris, \`!\` negasi.
269
+ ` + DEFAULT_PROTECTED_PATTERNS.join("\n") + `
270
+ `
271
+ );
272
+ }
273
+ }
274
+
275
+ // src/scope/scope.ts
276
+ function computeScope(highFiles, mediumFiles, testFiles, patterns) {
277
+ const required = [];
278
+ const optional = [];
279
+ const protectedFiles = [];
280
+ const seen = /* @__PURE__ */ new Set();
281
+ const place = (f) => {
282
+ if (seen.has(f)) return;
283
+ seen.add(f);
284
+ if (isProtected(f, patterns)) protectedFiles.push(f);
285
+ else if (highFiles.includes(f)) required.push(f);
286
+ else optional.push(f);
287
+ };
288
+ for (const f of highFiles) place(f);
289
+ for (const f of mediumFiles) place(f);
290
+ for (const t of testFiles) {
291
+ if (seen.has(t)) continue;
292
+ seen.add(t);
293
+ if (isProtected(t, patterns)) protectedFiles.push(t);
294
+ else optional.push(t);
295
+ }
296
+ return { required, optional, protected: protectedFiles };
297
+ }
298
+ function scopeBlock(scope) {
299
+ const fmt = (xs) => xs.length > 0 ? xs.join(", ") : "-";
300
+ return `## Scope
301
+ Required: ${fmt(scope.required)}
302
+ Optional: ${fmt(scope.optional)}
303
+ Protected: ${fmt(scope.protected)}
304
+ `;
305
+ }
306
+
307
+ export {
308
+ dataDir,
309
+ dbPath,
310
+ openDb,
311
+ hashContent,
312
+ matchPattern,
313
+ loadProtectedPatterns,
314
+ isProtected,
315
+ isIndexableMetaFile,
316
+ ensureProtectedFile,
317
+ computeScope,
318
+ scopeBlock
319
+ };
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ computeScope,
4
+ loadProtectedPatterns,
5
+ scopeBlock
6
+ } from "./chunk-4ZV67SRY.js";
7
+
8
+ // src/scope/guard.ts
9
+ var TASK_GUARD_RULES = [
10
+ "Ubah hanya file Required dan Optional; Protected perlu persetujuan user.",
11
+ "Jangan refactor, upgrade dependency, atau abstraksi prematur yang tak diminta.",
12
+ "Jangan tulis ulang kode berjalan; jangan tambah file/komentar tak perlu.",
13
+ "Berhenti saat acceptance criteria terpenuhi."
14
+ ];
15
+ var DEFINITION_OF_DONE = [
16
+ "Fungsionalitas yang diminta terimplementasi",
17
+ "Test relevan lulus",
18
+ "Tidak ada file berubah tanpa alasan",
19
+ "Tidak ada refactoring tak terkait",
20
+ "Acceptance criteria terpenuhi",
21
+ "Index dimutakhirkan (`scopecairn scan`)"
22
+ ];
23
+ function guardBlock() {
24
+ return `## Task Guard
25
+ ${TASK_GUARD_RULES.map((r) => `- ${r}`).join("\n")}
26
+
27
+ ## Definition of Done
28
+ ${DEFINITION_OF_DONE.map((d) => `- [ ] ${d}`).join("\n")}
29
+ `;
30
+ }
31
+
32
+ // src/retrieval/contextBuilder.ts
33
+ function buildContext(db, task, result, cls, opts) {
34
+ const complex = cls.complexity === "COMPLEX" || opts?.escalate === true;
35
+ if (!complex) {
36
+ const files = [];
37
+ for (const r of result.ranked) {
38
+ if (!files.includes(r.file)) files.push(r.file);
39
+ if (files.length >= 3) break;
40
+ }
41
+ const cand = files.length > 0 ? files.map((f) => `\`${f}\``).join(", ") : "pencarian langsung (tidak ada simbol cocok di index)";
42
+ return {
43
+ markdown: `# Task Context
44
+
45
+ ## Task
46
+ ${task}
47
+
48
+ ## Complexity
49
+ SIMPLE (${cls.reasons.join("; ")})
50
+
51
+ Task sederhana. Gunakan pencarian langsung. Kandidat: ${cand}
52
+ `,
53
+ high: [],
54
+ medium: [],
55
+ tests: [],
56
+ dependencies: []
57
+ };
58
+ }
59
+ const ranked = result.ranked;
60
+ const hi = ranked.filter((r) => r.score >= 0.3).slice(0, 10);
61
+ const med = ranked.filter((r) => r.score >= 0.15 && r.score < 0.3).slice(0, 10);
62
+ const highFiles = [...new Set(hi.map((r) => r.file))];
63
+ const medFiles = [...new Set(med.map((r) => r.file))].filter((f) => !highFiles.includes(f));
64
+ const deps = [];
65
+ if (highFiles.length > 0) {
66
+ const ph = highFiles.map(() => "?").join(",");
67
+ try {
68
+ const rows = db.prepare(
69
+ `SELECT DISTINCT f1.path AS a, f2.path AS b
70
+ FROM relationships r
71
+ JOIN symbols s1 ON s1.id = r.source_id
72
+ JOIN symbols s2 ON s2.id = r.target_id
73
+ JOIN files f1 ON f1.id = s1.file_id
74
+ JOIN files f2 ON f2.id = s2.file_id
75
+ WHERE r.relationship_type IN ('IMPORTS','CALLS')
76
+ AND f1.path IN (${ph}) AND f2.path IN (${ph}) AND f1.path != f2.path
77
+ LIMIT 10`
78
+ ).all(...highFiles, ...highFiles);
79
+ for (const r of rows) deps.push(`${r.a} \u2192 ${r.b}`);
80
+ } catch {
81
+ }
82
+ }
83
+ const isTestFileLike = (p) => p.includes(".test.") || p.includes(".spec.") || p.includes("__tests__") || (p.split("/").pop() ?? "").startsWith("test_");
84
+ const testSet = /* @__PURE__ */ new Set();
85
+ if (highFiles.length > 0) {
86
+ const ph = highFiles.map(() => "?").join(",");
87
+ try {
88
+ const rows = db.prepare(
89
+ `SELECT DISTINCT f2.path AS t
90
+ FROM relationships r
91
+ JOIN symbols s1 ON s1.id = r.source_id
92
+ JOIN symbols s2 ON s2.id = r.target_id
93
+ JOIN files f1 ON f1.id = s1.file_id
94
+ JOIN files f2 ON f2.id = s2.file_id
95
+ WHERE r.relationship_type = 'TESTS'
96
+ AND (f1.path IN (${ph}) OR f2.path IN (${ph}))`
97
+ ).all(...highFiles, ...highFiles);
98
+ for (const r of rows) testSet.add(r.t);
99
+ } catch {
100
+ }
101
+ try {
102
+ const symRows = db.prepare(
103
+ `SELECT s.id AS id FROM symbols s
104
+ JOIN files f ON f.id = s.file_id
105
+ WHERE f.path IN (${ph}) LIMIT 50`
106
+ ).all(...highFiles);
107
+ const targetIds = symRows.map((r) => r.id);
108
+ if (targetIds.length > 0) {
109
+ const inClause = targetIds.map(() => "?").join(",");
110
+ const callerRows = db.prepare(
111
+ `SELECT DISTINCT s.file_id AS fid, f.path AS p
112
+ FROM relationships r
113
+ JOIN symbols s ON s.id = r.source_id
114
+ JOIN files f ON f.id = s.file_id
115
+ WHERE r.target_id IN (${inClause})`
116
+ ).all(...targetIds);
117
+ const intermediateIds = [];
118
+ for (const c of callerRows) {
119
+ if (isTestFileLike(c.p)) testSet.add(c.p);
120
+ }
121
+ try {
122
+ const idRows = db.prepare(`SELECT DISTINCT r.source_id AS id FROM relationships r WHERE r.target_id IN (${inClause}) LIMIT 100`).all(...targetIds);
123
+ intermediateIds.push(...idRows.map((r) => r.id));
124
+ } catch {
125
+ }
126
+ if (intermediateIds.length > 0) {
127
+ const interClause = intermediateIds.map(() => "?").join(",");
128
+ const indirectRows = db.prepare(
129
+ `SELECT DISTINCT f.path AS p
130
+ FROM relationships r
131
+ JOIN symbols s ON s.id = r.source_id
132
+ JOIN files f ON f.id = s.file_id
133
+ WHERE r.target_id IN (${interClause})`
134
+ ).all(...intermediateIds);
135
+ for (const r of indirectRows) {
136
+ if (isTestFileLike(r.p)) testSet.add(r.p);
137
+ }
138
+ }
139
+ }
140
+ } catch {
141
+ }
142
+ }
143
+ for (const r of ranked) {
144
+ if (isTestFileLike(r.file)) testSet.add(r.file);
145
+ }
146
+ const tests = [...testSet].slice(0, 10);
147
+ const symLine = (r) => `- ${r.name} (${r.type}) \u2014 \`${r.file}\` [${r.score.toFixed(2)} ${r.reason}]`;
148
+ let hotspots = [];
149
+ let cycleWarn = "";
150
+ try {
151
+ const ids = ranked.slice(0, 20).map((r) => r.id);
152
+ if (ids.length > 0) {
153
+ const ph = ids.map(() => "?").join(",");
154
+ const prRows = db.prepare(`SELECT node_id AS id, pagerank AS pr FROM node_metrics WHERE node_id IN (${ph})`).all(...ids);
155
+ const pr = new Map(prRows.map((r) => [r.id, r.pr]));
156
+ const boosted = ranked.slice(0, 20).map((r) => ({ r, boost: r.score + (pr.get(r.id) ?? 0) * 2 })).sort((a, b) => b.boost - a.boost).slice(0, 5);
157
+ hotspots = boosted.map(
158
+ (b) => `- ${b.r.name} (${b.r.type}) \u2014 \`${b.r.file}\` [pr=${(pr.get(b.r.id) ?? 0).toFixed(4)}]`
159
+ );
160
+ const cycRows = db.prepare(`SELECT nodes_json AS js FROM cycles`).all();
161
+ const inCycle = /* @__PURE__ */ new Set();
162
+ for (const c of cycRows) {
163
+ try {
164
+ for (const n of JSON.parse(c.js)) inCycle.add(n);
165
+ } catch {
166
+ }
167
+ }
168
+ const trapped = ranked.slice(0, 10).filter((r) => inCycle.has(r.id));
169
+ if (trapped.length > 0) {
170
+ cycleWarn = `
171
+ ## Circular dependency warning
172
+ ` + trapped.slice(0, 5).map((r) => `- ${r.name} (\`${r.file}\`) is inside a dependency cycle`).join("\n") + `
173
+ `;
174
+ }
175
+ }
176
+ } catch {
177
+ }
178
+ const scope = computeScope(
179
+ highFiles,
180
+ medFiles,
181
+ tests,
182
+ loadProtectedPatterns(opts?.repoRoot ?? ".")
183
+ );
184
+ const md = `# Task Context
185
+
186
+ ## Task
187
+ ${task}
188
+
189
+ ## Complexity
190
+ ${cls.complexity} (${cls.reasons.join("; ")})
191
+
192
+ ## Relevant Files
193
+ ### HIGH
194
+ ${highFiles.map((f) => `- ${f}`).join("\n") || "-"}
195
+ ### MEDIUM
196
+ ${medFiles.map((f) => `- ${f}`).join("\n") || "-"}
197
+
198
+ ## Key Symbols
199
+ ${hi.slice(0, 8).map(symLine).join("\n") || "-"}
200
+
201
+ ` + (hotspots.length > 0 ? `## Critical hotspots (PageRank)
202
+ ${hotspots.join("\n")}
203
+
204
+ ` : ``) + `## Dependencies
205
+ ${deps.map((d) => `- ${d}`).join("\n") || "-"}
206
+
207
+ ## Related Tests
208
+ ${tests.map((t) => `- ${t}`).join("\n") || "-"}
209
+
210
+ ` + cycleWarn + scopeBlock(scope) + `
211
+ ` + guardBlock();
212
+ return { markdown: md, high: highFiles, medium: medFiles, tests, dependencies: deps };
213
+ }
214
+
215
+ export {
216
+ buildContext
217
+ };