scopecairn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/cli.js +2423 -0
  4. package/package.json +48 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2423 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import path13 from "path";
6
+
7
+ // src/scanner.ts
8
+ import fs5 from "fs";
9
+ import path6 from "path";
10
+
11
+ // src/languages.ts
12
+ var EXT_MAP = {
13
+ ".ts": "typescript",
14
+ ".tsx": "typescript",
15
+ ".js": "javascript",
16
+ ".jsx": "javascript",
17
+ ".py": "python"
18
+ };
19
+ function detectLanguage(filePath) {
20
+ const dot = filePath.lastIndexOf(".");
21
+ if (dot === -1) return "other";
22
+ const ext = filePath.slice(dot).toLowerCase();
23
+ return EXT_MAP[ext] ?? "other";
24
+ }
25
+ function isSourceFile(filePath) {
26
+ return detectLanguage(filePath) !== "other";
27
+ }
28
+
29
+ // src/db.ts
30
+ import fs from "fs";
31
+ import path from "path";
32
+ import crypto from "crypto";
33
+ import { createRequire } from "module";
34
+ function loadDatabaseSync() {
35
+ const req = createRequire(import.meta.url);
36
+ const spec = "node:sqlite";
37
+ const m = req(spec);
38
+ return m.DatabaseSync;
39
+ }
40
+ var DATA_DIR_NAME = ".scopecairn";
41
+ var DB_FILE_NAME = "scopecairn.db";
42
+ var SCHEMA_VERSION = 4;
43
+ function dataDir(repoRoot2) {
44
+ return path.join(repoRoot2, DATA_DIR_NAME);
45
+ }
46
+ function dbPath(repoRoot2) {
47
+ return path.join(dataDir(repoRoot2), DB_FILE_NAME);
48
+ }
49
+ function openDb(repoRoot2) {
50
+ fs.mkdirSync(dataDir(repoRoot2), { recursive: true });
51
+ const DatabaseSync = loadDatabaseSync();
52
+ const db = new DatabaseSync(dbPath(repoRoot2));
53
+ db.exec(`
54
+ PRAGMA journal_mode = WAL;
55
+ PRAGMA foreign_keys = ON;
56
+ CREATE TABLE IF NOT EXISTS files (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ path TEXT NOT NULL UNIQUE,
59
+ language TEXT NOT NULL,
60
+ hash TEXT NOT NULL,
61
+ size INTEGER NOT NULL,
62
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
63
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
64
+ );
65
+ CREATE TABLE IF NOT EXISTS symbols (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
68
+ name TEXT NOT NULL,
69
+ type TEXT NOT NULL,
70
+ signature TEXT NOT NULL DEFAULT '',
71
+ start_line INTEGER NOT NULL DEFAULT 1,
72
+ end_line INTEGER NOT NULL DEFAULT 1
73
+ );
74
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
75
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
76
+ CREATE INDEX IF NOT EXISTS idx_symbols_type ON symbols(type);
77
+ CREATE TABLE IF NOT EXISTS relationships (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ source_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
80
+ target_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
81
+ relationship_type TEXT NOT NULL,
82
+ weight REAL NOT NULL DEFAULT 0.5,
83
+ confidence REAL NOT NULL DEFAULT 1.0
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_id);
86
+ CREATE INDEX IF NOT EXISTS idx_rel_target ON relationships(target_id);
87
+ CREATE INDEX IF NOT EXISTS idx_rel_type ON relationships(relationship_type);
88
+ CREATE TABLE IF NOT EXISTS meta (
89
+ key TEXT PRIMARY KEY,
90
+ value TEXT NOT NULL
91
+ );
92
+ -- Fase 3: FTS5 symbol index (token match, bukan embedding).
93
+ CREATE VIRTUAL TABLE IF NOT EXISTS symbol_index USING fts5(
94
+ symbol_id UNINDEXED, tokens
95
+ );
96
+ CREATE TABLE IF NOT EXISTS tasks (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ description TEXT NOT NULL,
99
+ complexity TEXT NOT NULL DEFAULT '',
100
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
101
+ completed_at TEXT
102
+ );
103
+ CREATE TABLE IF NOT EXISTS task_context (
104
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
105
+ symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
106
+ score REAL NOT NULL DEFAULT 0,
107
+ reason TEXT NOT NULL DEFAULT ''
108
+ );
109
+ CREATE INDEX IF NOT EXISTS idx_task_context_task ON task_context(task_id);
110
+ -- Fase 5: invocation log untuk compliance rate (AI-5, \xA718).
111
+ CREATE TABLE IF NOT EXISTS invocations (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
114
+ command TEXT NOT NULL,
115
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
116
+ );
117
+ CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command);
118
+ `);
119
+ db.prepare(
120
+ `INSERT INTO meta(key, value) VALUES ('schema_version', ?)
121
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
122
+ ).run(String(SCHEMA_VERSION));
123
+ return db;
124
+ }
125
+ function hashContent(buf) {
126
+ return crypto.createHash("sha256").update(buf).digest("hex");
127
+ }
128
+
129
+ // src/ignore.ts
130
+ import fs2 from "fs";
131
+ import path2 from "path";
132
+ import ignore from "ignore";
133
+ var DEFAULT_DIR_IGNORES = [
134
+ "node_modules/",
135
+ ".git/",
136
+ ".next/",
137
+ "dist/",
138
+ "build/",
139
+ "coverage/",
140
+ ".scopecairn/"
141
+ ];
142
+ var DEFAULT_FILE_IGNORES = [
143
+ ".env",
144
+ ".env.*",
145
+ "*.pem",
146
+ "*.key",
147
+ "credentials.*",
148
+ "secrets.*",
149
+ "*.min.js",
150
+ "*.map"
151
+ ];
152
+ function loadIgnoreRules(repoRoot2) {
153
+ const ig = ignore();
154
+ ig.add(DEFAULT_DIR_IGNORES);
155
+ ig.add(DEFAULT_FILE_IGNORES);
156
+ for (const name of [".gitignore", ".scopecairnignore"]) {
157
+ const p = path2.join(repoRoot2, name);
158
+ if (fs2.existsSync(p)) {
159
+ try {
160
+ const content = fs2.readFileSync(p, "utf8");
161
+ ig.add(content);
162
+ } catch {
163
+ }
164
+ }
165
+ }
166
+ return { ig };
167
+ }
168
+ function isIgnored(relPosix, rules) {
169
+ return rules.ig.ignores(relPosix);
170
+ }
171
+
172
+ // src/indexer.ts
173
+ import path3 from "path";
174
+
175
+ // src/extract/types.ts
176
+ var WEIGHT = {
177
+ CONTAINS: 1,
178
+ IMPORTS: 0.9,
179
+ EXPORTS: 0.9,
180
+ TESTS: 0.9,
181
+ EXTENDS: 0.8,
182
+ IMPLEMENTS: 0.8,
183
+ CALLS: 0.7,
184
+ REFERENCES: 0.5,
185
+ USES: 0.5
186
+ };
187
+
188
+ // src/extract/typescript.ts
189
+ function lineOf(text, index) {
190
+ return text.slice(0, index).split("\n").length;
191
+ }
192
+ function stripStringsAndComments(src) {
193
+ let out = "";
194
+ let i = 0;
195
+ let quote = null;
196
+ while (i < src.length) {
197
+ const c = src[i];
198
+ const nxt = src[i + 1] ?? "";
199
+ if (quote) {
200
+ if (c === "\\") {
201
+ out += " ";
202
+ i += 2;
203
+ continue;
204
+ }
205
+ if (c === "\n") {
206
+ out += "\n";
207
+ if (quote !== "`") quote = null;
208
+ i++;
209
+ continue;
210
+ }
211
+ if (c === quote) quote = null;
212
+ out += " ";
213
+ i++;
214
+ continue;
215
+ }
216
+ if (c === '"' || c === "'" || c === "`") {
217
+ quote = c;
218
+ out += " ";
219
+ i++;
220
+ continue;
221
+ }
222
+ if (c === "/" && nxt === "/") {
223
+ while (i < src.length && src[i] !== "\n") {
224
+ out += " ";
225
+ i++;
226
+ }
227
+ continue;
228
+ }
229
+ if (c === "/" && nxt === "*") {
230
+ out += " ";
231
+ i += 2;
232
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
233
+ out += src[i] === "\n" ? "\n" : " ";
234
+ i++;
235
+ }
236
+ out += " ";
237
+ i += 2;
238
+ continue;
239
+ }
240
+ out += c;
241
+ i++;
242
+ }
243
+ return out;
244
+ }
245
+ function extractTypeScript(source, isTsx) {
246
+ const symbols = [];
247
+ const relations = [];
248
+ const pushRel = (from, to, rel, confidence = 1) => relations.push({ from, to, rel, weight: WEIGHT[rel], confidence });
249
+ const importRe = /import\s+(?:(?:[\w*{}\s,]+?)\s+from\s+)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/g;
250
+ let m;
251
+ while ((m = importRe.exec(source)) !== null) {
252
+ const mod = m[1] ?? m[2];
253
+ if (!mod) continue;
254
+ const ln = lineOf(source, m.index);
255
+ symbols.push({
256
+ name: `import:${mod}`,
257
+ type: "import",
258
+ signature: m[0].slice(0, 120),
259
+ startLine: ln,
260
+ endLine: ln
261
+ });
262
+ const dynamic = m[0].trimStart().startsWith("import(");
263
+ pushRel("__file__", mod, "IMPORTS", dynamic ? 0.6 : 1);
264
+ }
265
+ const exportStarRe = /export\s+\*\s+from\s+["']([^"']+)["']/g;
266
+ while ((m = exportStarRe.exec(source)) !== null) {
267
+ const mod = m[1];
268
+ const ln = lineOf(source, m.index);
269
+ symbols.push({
270
+ name: `export*:${mod}`,
271
+ type: "export",
272
+ signature: m[0].slice(0, 120),
273
+ startLine: ln,
274
+ endLine: ln
275
+ });
276
+ pushRel("__file__", mod, "EXPORTS", 0.7);
277
+ }
278
+ const funcRe = /(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)\s*\(|(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*async\s*\(/g;
279
+ const funcNames = /* @__PURE__ */ new Set();
280
+ while ((m = funcRe.exec(source)) !== null) {
281
+ const name = m[1] ?? m[2];
282
+ if (!name) continue;
283
+ funcNames.add(name);
284
+ const ln = lineOf(source, m.index);
285
+ const looksJsx = isTsx && /^[A-Z]/.test(name) && /<[A-Z][\w]*|<>|return\s*\(/.test(source.slice(m.index, m.index + 2e3));
286
+ symbols.push({
287
+ name,
288
+ type: looksJsx ? "component" : "function",
289
+ signature: m[0].slice(0, 120),
290
+ startLine: ln,
291
+ endLine: ln
292
+ });
293
+ if (/^export\s/.test(m[0])) pushRel("__file__", name, "EXPORTS", 1);
294
+ }
295
+ const arrowRe = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:<[^=]*>)?\([^)]*\)\s*=>/g;
296
+ while ((m = arrowRe.exec(source)) !== null) {
297
+ const name = m[1];
298
+ if (funcNames.has(name)) continue;
299
+ funcNames.add(name);
300
+ const ln = lineOf(source, m.index);
301
+ symbols.push({
302
+ name,
303
+ type: /^[A-Z]/.test(name) && isTsx ? "component" : "function",
304
+ signature: m[0].slice(0, 120),
305
+ startLine: ln,
306
+ endLine: ln
307
+ });
308
+ if (/^export\s/.test(m[0])) pushRel("__file__", name, "EXPORTS", 1);
309
+ }
310
+ const classRe = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w\s,]+))?/g;
311
+ const classNames = /* @__PURE__ */ new Set();
312
+ while ((m = classRe.exec(source)) !== null) {
313
+ const [, name, base, impls] = m;
314
+ classNames.add(name);
315
+ const ln = lineOf(source, m.index);
316
+ symbols.push({
317
+ name,
318
+ type: "class",
319
+ signature: m[0].slice(0, 120),
320
+ startLine: ln,
321
+ endLine: ln
322
+ });
323
+ if (base) pushRel(name, base, "EXTENDS", 1);
324
+ if (impls)
325
+ for (const i of impls.split(",")) {
326
+ const t = i.trim();
327
+ if (t) pushRel(name, t, "IMPLEMENTS", 1);
328
+ }
329
+ if (/^export\s/.test(m[0])) pushRel("__file__", name, "EXPORTS", 1);
330
+ }
331
+ const methodRe = /(?:^|\n)\s*(?:public|private|protected|static|async|override|readonly|\s)*\s*(?:get\s+|set\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{=;]+)?\s*\{/g;
332
+ while ((m = methodRe.exec(source)) !== null) {
333
+ const name = m[1];
334
+ if (["if", "for", "while", "switch", "catch", "function"].includes(name)) continue;
335
+ const ln = lineOf(source, m.index);
336
+ symbols.push({
337
+ name,
338
+ type: "method",
339
+ signature: m[0].trim().slice(0, 120),
340
+ startLine: ln,
341
+ endLine: ln
342
+ });
343
+ }
344
+ const ifaceRe = /(?:export\s+)?interface\s+(\w+)/g;
345
+ while ((m = ifaceRe.exec(source)) !== null) {
346
+ const ln = lineOf(source, m.index);
347
+ symbols.push({
348
+ name: m[1],
349
+ type: "interface",
350
+ signature: m[0].slice(0, 120),
351
+ startLine: ln,
352
+ endLine: ln
353
+ });
354
+ }
355
+ const typeRe = /(?:export\s+)?type\s+(\w+)\s*=/g;
356
+ while ((m = typeRe.exec(source)) !== null) {
357
+ const ln = lineOf(source, m.index);
358
+ symbols.push({
359
+ name: m[1],
360
+ type: "type",
361
+ signature: m[0].slice(0, 120),
362
+ startLine: ln,
363
+ endLine: ln
364
+ });
365
+ }
366
+ const varRe = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*[:=]/g;
367
+ while ((m = varRe.exec(source)) !== null) {
368
+ const name = m[1];
369
+ if (funcNames.has(name)) continue;
370
+ const ln = lineOf(source, m.index);
371
+ symbols.push({
372
+ name,
373
+ type: "variable",
374
+ signature: m[0].slice(0, 120),
375
+ startLine: ln,
376
+ endLine: ln
377
+ });
378
+ }
379
+ const callSource = stripStringsAndComments(source);
380
+ const callRe = /(\w+)\s*\(/g;
381
+ const keywords = /* @__PURE__ */ new Set([
382
+ "if",
383
+ "for",
384
+ "while",
385
+ "switch",
386
+ "catch",
387
+ "return",
388
+ "function",
389
+ "class",
390
+ "import",
391
+ "export",
392
+ "new",
393
+ "typeof",
394
+ "instanceof"
395
+ ]);
396
+ const funcsByLine = symbols.filter((s) => s.type === "function" || s.type === "component" || s.type === "method").sort((a, b) => a.startLine - b.startLine);
397
+ let guard = 0;
398
+ while ((m = callRe.exec(callSource)) !== null && guard++ < 4e3) {
399
+ const callee = m[1];
400
+ if (keywords.has(callee)) continue;
401
+ const ln = lineOf(callSource, m.index);
402
+ let caller = "__file__";
403
+ for (const f of funcsByLine) {
404
+ if (f.startLine <= ln) caller = f.name;
405
+ else break;
406
+ }
407
+ if (caller === callee) continue;
408
+ relations.push({
409
+ from: caller,
410
+ to: callee,
411
+ rel: "CALLS",
412
+ weight: WEIGHT.CALLS,
413
+ confidence: 0.7,
414
+ // regex can't resolve scope precisely
415
+ methodCall: callSource[m.index - 1] === "."
416
+ });
417
+ }
418
+ return { symbols, relations };
419
+ }
420
+
421
+ // src/extract/python.ts
422
+ function lineOf2(text, index) {
423
+ return text.slice(0, index).split("\n").length;
424
+ }
425
+ function extractPython(source) {
426
+ const symbols = [];
427
+ const relations = [];
428
+ const push = (from, to, rel, confidence = 1) => relations.push({ from, to, rel, weight: WEIGHT[rel], confidence });
429
+ let m;
430
+ const importRe = /^\s*(?:import\s+([\w.,\s]+)|from\s+([\w.]+)\s+import\s+([^\n#]+))/gm;
431
+ while ((m = importRe.exec(source)) !== null) {
432
+ const mod = (m[2] ?? m[1] ?? "").trim();
433
+ if (!mod) continue;
434
+ const ln = lineOf2(source, m.index);
435
+ symbols.push({
436
+ name: `import:${mod}`,
437
+ type: "import",
438
+ signature: m[0].trim().slice(0, 120),
439
+ startLine: ln,
440
+ endLine: ln
441
+ });
442
+ push("__file__", mod, "IMPORTS", 1);
443
+ }
444
+ const classRe = /^\s*class\s+(\w+)(?:\s*\(([^)]*)\))?\s*:/gm;
445
+ while ((m = classRe.exec(source)) !== null) {
446
+ const ln = lineOf2(source, m.index);
447
+ symbols.push({
448
+ name: m[1],
449
+ type: "class",
450
+ signature: m[0].trim().slice(0, 120),
451
+ startLine: ln,
452
+ endLine: ln
453
+ });
454
+ const bases = (m[2] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
455
+ for (const b of bases) {
456
+ if (!["object"].includes(b)) push(m[1], b, "EXTENDS", 0.9);
457
+ }
458
+ }
459
+ const defRe = /^\s*(?:async\s+)?def\s+(\w+)\s*\(/gm;
460
+ const funcs = [];
461
+ while ((m = defRe.exec(source)) !== null) {
462
+ const ln = lineOf2(source, m.index);
463
+ const indented = /^\s+/.test(m[0]);
464
+ symbols.push({
465
+ name: m[1],
466
+ type: indented ? "method" : "function",
467
+ signature: m[0].trim().slice(0, 120),
468
+ startLine: ln,
469
+ endLine: ln
470
+ });
471
+ funcs.push({ name: m[1], line: ln });
472
+ }
473
+ funcs.sort((a, b) => a.line - b.line);
474
+ const callRe = /(\w+)\s*\(/g;
475
+ const kw = /* @__PURE__ */ new Set(["def", "class", "if", "for", "while", "return", "import", "print"]);
476
+ let guard = 0;
477
+ while ((m = callRe.exec(source)) !== null && guard++ < 4e3) {
478
+ const callee = m[1];
479
+ if (kw.has(callee)) continue;
480
+ const ln = lineOf2(source, m.index);
481
+ let caller = "__file__";
482
+ for (const f of funcs) {
483
+ if (f.line <= ln) caller = f.name;
484
+ else break;
485
+ }
486
+ if (caller === callee) continue;
487
+ relations.push({
488
+ from: caller,
489
+ to: callee,
490
+ rel: "CALLS",
491
+ weight: WEIGHT.CALLS,
492
+ confidence: 0.7,
493
+ methodCall: source[m.index - 1] === "."
494
+ });
495
+ }
496
+ return { symbols, relations };
497
+ }
498
+
499
+ // src/extract/index.ts
500
+ function extractFile(relPath, source) {
501
+ const lang = detectLanguage(relPath);
502
+ switch (lang) {
503
+ case "typescript":
504
+ return extractTypeScript(source, relPath.endsWith("x"));
505
+ case "javascript":
506
+ return extractTypeScript(source, relPath.endsWith("x"));
507
+ case "python":
508
+ return extractPython(source);
509
+ default:
510
+ return { symbols: [], relations: [] };
511
+ }
512
+ }
513
+ function testTarget(testRel) {
514
+ const base = testRel.split("/").pop() ?? testRel;
515
+ let stem = null;
516
+ const m1 = base.match(/^(.+)\.(test|spec)\.[^.]+$/);
517
+ if (m1) stem = m1[1];
518
+ const m2 = base.match(/^test_(.+)\.py$/);
519
+ if (m2) stem = m2[1];
520
+ const m3 = base.match(/^(.+)_test\.py$/);
521
+ if (m3) stem = m3[1];
522
+ if (!stem) return null;
523
+ const dir = testRel.includes("/") ? testRel.slice(0, testRel.lastIndexOf("/")) : "";
524
+ return (dir ? dir + "/" : "") + stem;
525
+ }
526
+
527
+ // src/retrieval/tokenize.ts
528
+ var STOP = /* @__PURE__ */ new Set([
529
+ "the",
530
+ "a",
531
+ "an",
532
+ "of",
533
+ "to",
534
+ "in",
535
+ "for",
536
+ "on",
537
+ "with",
538
+ "and",
539
+ "or",
540
+ "src",
541
+ "lib",
542
+ "app",
543
+ "test",
544
+ "tests",
545
+ "spec"
546
+ ]);
547
+ function tokenizeIdentifier(raw) {
548
+ 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 && !/^\d+$/.test(t) && !STOP.has(t));
549
+ return [...new Set(parts)];
550
+ }
551
+ function tokenizeTask(text) {
552
+ return tokenizeIdentifier(text);
553
+ }
554
+
555
+ // src/retrieval/symbolIndex.ts
556
+ function syncFileIndex(db, fileId, relPath) {
557
+ db.prepare(
558
+ `DELETE FROM symbol_index WHERE symbol_id IN (SELECT id FROM symbols WHERE file_id = ?)`
559
+ ).run(fileId);
560
+ const rows = db.prepare(
561
+ `SELECT id, name, type, signature FROM symbols WHERE file_id = ?`
562
+ ).all(fileId);
563
+ const ins = db.prepare(
564
+ `INSERT INTO symbol_index(symbol_id, tokens) VALUES (?, ?)`
565
+ );
566
+ for (const r of rows) {
567
+ const toks = tokenizeIdentifier(`${r.name} ${r.signature} ${relPath}`).join(" ");
568
+ if (toks) ins.run(r.id, toks);
569
+ }
570
+ }
571
+ function cleanupIndex(db) {
572
+ db.exec(
573
+ `DELETE FROM symbol_index WHERE symbol_id NOT IN (SELECT id FROM symbols)`
574
+ );
575
+ }
576
+ function seedSearch(db, tokens, limit = 30) {
577
+ if (tokens.length === 0) return [];
578
+ const q = tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
579
+ let rows;
580
+ try {
581
+ rows = db.prepare(
582
+ `SELECT symbol_id, bm25(symbol_index) AS rank
583
+ FROM symbol_index WHERE symbol_index MATCH ? LIMIT ${limit}`
584
+ ).all(q);
585
+ } catch {
586
+ return [];
587
+ }
588
+ if (rows.length === 0) return [];
589
+ const worst = Math.min(...rows.map((r) => r.rank));
590
+ const range = Math.max(1e-9, 0 - worst + 1);
591
+ return rows.map((r) => ({
592
+ symbolId: r.symbol_id,
593
+ bm25: Math.max(0, Math.min(1, (0 - r.rank + 0.5) / range))
594
+ }));
595
+ }
596
+
597
+ // src/indexer.ts
598
+ function fileSymbolId(db, fileId, relPath) {
599
+ const existing = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND type = 'file'`).get(fileId);
600
+ if (existing) return existing.id;
601
+ const r = db.prepare(
602
+ `INSERT INTO symbols(file_id, name, type, signature, start_line, end_line)
603
+ VALUES (?, ?, 'file', '', 1, 1)`
604
+ ).run(fileId, relPath);
605
+ return Number(r.lastInsertRowid);
606
+ }
607
+ function symbolIdInFile(db, fileId, name) {
608
+ const r = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND name = ? LIMIT 1`).get(fileId, name);
609
+ return r ? r.id : null;
610
+ }
611
+ function globalSymbolId(db, name) {
612
+ const r = db.prepare(`SELECT id FROM symbols WHERE name = ? LIMIT 1`).get(name);
613
+ return r ? r.id : null;
614
+ }
615
+ function methodSymbolId(db, fileId, ids, name) {
616
+ const local = ids.get(name);
617
+ if (local) {
618
+ const t = db.prepare(`SELECT type FROM symbols WHERE id = ?`).get(local);
619
+ if (t && ["method", "component"].includes(t.type)) return local;
620
+ }
621
+ const r = db.prepare(
622
+ `SELECT id FROM symbols WHERE name = ? AND type IN ('method','component') LIMIT 1`
623
+ ).get(name);
624
+ return r ? r.id : null;
625
+ }
626
+ function resolveModuleToFile(db, fromRel, spec) {
627
+ if (!spec.startsWith(".")) return null;
628
+ const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
629
+ const joined = path3.posix.normalize((dir ? dir + "/" : "") + spec);
630
+ const bases = [joined];
631
+ const jsExt = joined.match(/\.(js|mjs|cjs|jsx)$/);
632
+ if (jsExt) {
633
+ const stripped = joined.slice(0, -jsExt[0].length);
634
+ bases.push(stripped, stripped + ".ts", stripped + ".tsx");
635
+ }
636
+ const candidates = [
637
+ ...bases,
638
+ joined + ".ts",
639
+ joined + ".tsx",
640
+ joined + ".js",
641
+ joined + ".jsx",
642
+ joined + ".py",
643
+ joined + "/index.ts",
644
+ joined + "/index.js"
645
+ ];
646
+ for (const c of candidates) {
647
+ const r = db.prepare(`SELECT id FROM files WHERE path = ?`).get(c);
648
+ if (r) {
649
+ const s = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND type = 'file'`).get(r.id);
650
+ if (s) return s.id;
651
+ }
652
+ }
653
+ return null;
654
+ }
655
+ function indexFileSymbols(db, fileId, relPath, content) {
656
+ db.prepare(`DELETE FROM symbols WHERE file_id = ?`).run(fileId);
657
+ const fileSym = fileSymbolId(db, fileId, relPath);
658
+ const ext = extractFile(relPath, content);
659
+ const insSym = db.prepare(
660
+ `INSERT INTO symbols(file_id, name, type, signature, start_line, end_line)
661
+ VALUES (?, ?, ?, ?, ?, ?)`
662
+ );
663
+ for (const s of ext.symbols) {
664
+ if (s.type === "import" || s.type === "export") continue;
665
+ insSym.run(fileId, s.name, s.type, s.signature, s.startLine, s.endLine);
666
+ }
667
+ syncFileIndex(db, fileId, relPath);
668
+ return { extraction: ext, fileSym };
669
+ }
670
+ function indexFileRelations(db, fileId, relPath, extraction, fileSym) {
671
+ const ids = /* @__PURE__ */ new Map();
672
+ const rows = db.prepare(`SELECT id, name FROM symbols WHERE file_id = ? AND type != 'file'`).all(fileId);
673
+ for (const r of rows) if (!ids.has(r.name)) ids.set(r.name, r.id);
674
+ const insRel = db.prepare(
675
+ `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
676
+ VALUES (?, ?, ?, ?, ?)`
677
+ );
678
+ const contains = db.prepare(
679
+ `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
680
+ VALUES (?, ?, 'CONTAINS', 1.0, 1.0)`
681
+ );
682
+ let rels = 0;
683
+ for (const id of ids.values()) {
684
+ contains.run(fileSym, id);
685
+ rels++;
686
+ }
687
+ const resolveLocal = (name) => {
688
+ if (name === "__file__") return fileSym;
689
+ return ids.get(name) ?? symbolIdInFile(db, fileId, name) ?? globalSymbolId(db, name);
690
+ };
691
+ for (const r of extraction.relations) {
692
+ if (r.rel === "IMPORTS") {
693
+ const target = resolveModuleToFile(db, relPath, r.to);
694
+ if (target) {
695
+ insRel.run(fileSym, target, "IMPORTS", r.weight, r.confidence);
696
+ rels++;
697
+ }
698
+ continue;
699
+ }
700
+ if (r.rel === "EXPORTS") {
701
+ if (r.to.startsWith("export*:")) {
702
+ const target2 = resolveModuleToFile(db, relPath, r.to.slice(8));
703
+ if (target2) {
704
+ insRel.run(fileSym, target2, "EXPORTS", r.weight, r.confidence);
705
+ rels++;
706
+ }
707
+ continue;
708
+ }
709
+ const target = resolveLocal(r.to);
710
+ if (target && target !== fileSym) {
711
+ insRel.run(fileSym, target, "EXPORTS", r.weight, r.confidence);
712
+ rels++;
713
+ }
714
+ continue;
715
+ }
716
+ const src = resolveLocal(r.from);
717
+ const dst = r.rel === "CALLS" && r.methodCall ? methodSymbolId(db, fileId, ids, r.to) : ids.get(r.to) ?? globalSymbolId(db, r.to);
718
+ if (src && dst && src !== dst) {
719
+ insRel.run(src, dst, r.rel, r.weight, r.confidence);
720
+ rels++;
721
+ }
722
+ }
723
+ return { symbols: ids.size, relations: rels };
724
+ }
725
+ function indexMetaFile(db, fileId, relPath) {
726
+ db.prepare(`DELETE FROM symbols WHERE file_id = ?`).run(fileId);
727
+ fileSymbolId(db, fileId, relPath);
728
+ syncFileIndex(db, fileId, relPath);
729
+ }
730
+ function linkTests(db) {
731
+ const files = db.prepare(`SELECT id, path FROM files`).all();
732
+ const byPath = new Map(files.map((f) => [f.path, f.id]));
733
+ const fileSymOf = (fid) => {
734
+ const r = db.prepare(`SELECT id FROM symbols WHERE file_id = ? AND type = 'file'`).get(fid);
735
+ return r ? r.id : null;
736
+ };
737
+ db.prepare(`DELETE FROM relationships WHERE relationship_type = 'TESTS'`).run();
738
+ let n = 0;
739
+ const ins = db.prepare(
740
+ `INSERT INTO relationships(source_id, target_id, relationship_type, weight, confidence)
741
+ VALUES (?, ?, 'TESTS', 0.9, 0.8)`
742
+ );
743
+ for (const f of files) {
744
+ const stemBase = testTarget(f.path);
745
+ if (!stemBase) continue;
746
+ const matches = [...byPath.keys()].filter((p) => {
747
+ const noExt = p.replace(/\.[^.]+$/, "");
748
+ return noExt === stemBase && p !== f.path;
749
+ });
750
+ const srcSym = fileSymOf(f.id);
751
+ for (const mp of matches) {
752
+ const dstSym = fileSymOf(byPath.get(mp));
753
+ if (srcSym && dstSym) {
754
+ ins.run(srcSym, dstSym);
755
+ n++;
756
+ }
757
+ }
758
+ }
759
+ return n;
760
+ }
761
+
762
+ // src/retrieval/config.ts
763
+ import fs4 from "fs";
764
+ import path5 from "path";
765
+
766
+ // src/scope/protected.ts
767
+ import fs3 from "fs";
768
+ import path4 from "path";
769
+ var DEFAULT_PROTECTED_PATTERNS = [
770
+ "migrations/**",
771
+ "prisma/**",
772
+ "schema.prisma",
773
+ "*.sql",
774
+ "alembic/**",
775
+ ".env*",
776
+ "package.json",
777
+ "package-lock.json",
778
+ "pnpm-lock.yaml",
779
+ "yarn.lock",
780
+ "bun.lockb"
781
+ ];
782
+ function globToRegExp(pattern) {
783
+ let re = "";
784
+ let i = 0;
785
+ while (i < pattern.length) {
786
+ const c = pattern[i];
787
+ if (c === "*") {
788
+ if (pattern[i + 1] === "*") {
789
+ if (pattern[i + 2] === "/") {
790
+ re += "(.*/)?";
791
+ i += 3;
792
+ } else {
793
+ re += ".*";
794
+ i += 2;
795
+ }
796
+ } else {
797
+ re += "[^/]*";
798
+ i++;
799
+ }
800
+ } else if (c === "?") {
801
+ re += "[^/]";
802
+ i++;
803
+ } else {
804
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
805
+ i++;
806
+ }
807
+ }
808
+ return new RegExp(`^${re}$`);
809
+ }
810
+ function matchPattern(pattern, posixPath) {
811
+ const base = posixPath.split("/").pop() ?? posixPath;
812
+ if (pattern.endsWith("/")) {
813
+ const dir = pattern.slice(0, -1);
814
+ return posixPath === dir || posixPath.startsWith(dir + "/");
815
+ }
816
+ if (!pattern.includes("/")) {
817
+ return globToRegExp(pattern).test(base);
818
+ }
819
+ return globToRegExp(pattern).test(posixPath);
820
+ }
821
+ function loadProtectedPatterns(repoRoot2) {
822
+ const p = path4.join(dataDir(repoRoot2), "protected.yml");
823
+ if (!fs3.existsSync(p)) return [...DEFAULT_PROTECTED_PATTERNS];
824
+ try {
825
+ const patterns = [];
826
+ for (const line of fs3.readFileSync(p, "utf8").split("\n")) {
827
+ const t = line.trim();
828
+ if (!t || t.startsWith("#")) continue;
829
+ patterns.push(t);
830
+ }
831
+ return patterns.length > 0 ? patterns : [...DEFAULT_PROTECTED_PATTERNS];
832
+ } catch {
833
+ return [...DEFAULT_PROTECTED_PATTERNS];
834
+ }
835
+ }
836
+ function isProtected(posixPath, patterns = DEFAULT_PROTECTED_PATTERNS) {
837
+ let hit = false;
838
+ for (const p of patterns) {
839
+ if (p.startsWith("!")) {
840
+ if (matchPattern(p.slice(1), posixPath)) hit = false;
841
+ } else if (matchPattern(p, posixPath)) {
842
+ hit = true;
843
+ }
844
+ }
845
+ return hit;
846
+ }
847
+ var SECRET_PATTERNS = [
848
+ ".env",
849
+ ".env.*",
850
+ "*.pem",
851
+ "*.key",
852
+ "credentials.*",
853
+ "secrets.*"
854
+ ];
855
+ function isSecretFile(posixPath) {
856
+ return SECRET_PATTERNS.some((p) => matchPattern(p, posixPath));
857
+ }
858
+ var META_EXTS = /* @__PURE__ */ new Set([
859
+ ".json",
860
+ ".yaml",
861
+ ".yml",
862
+ ".toml",
863
+ ".sql",
864
+ ".prisma",
865
+ ".cfg",
866
+ ".ini"
867
+ ]);
868
+ var META_BASENAMES = /* @__PURE__ */ new Set([
869
+ "dockerfile",
870
+ "makefile",
871
+ "package.json"
872
+ ]);
873
+ function isIndexableMetaFile(posixPath) {
874
+ if (isSecretFile(posixPath)) return false;
875
+ const base = (posixPath.split("/").pop() ?? "").toLowerCase();
876
+ if (META_BASENAMES.has(base)) return true;
877
+ const dot = base.lastIndexOf(".");
878
+ if (dot !== -1 && META_EXTS.has(base.slice(dot))) return true;
879
+ if (isProtected(posixPath)) return true;
880
+ return false;
881
+ }
882
+ function ensureProtectedFile(repoRoot2) {
883
+ const dir = dataDir(repoRoot2);
884
+ fs3.mkdirSync(dir, { recursive: true });
885
+ const p = path4.join(dir, "protected.yml");
886
+ if (!fs3.existsSync(p)) {
887
+ fs3.writeFileSync(
888
+ p,
889
+ `# ScopeCairn Protected patterns (FR-07). Satu pola per baris, \`!\` negasi.
890
+ ` + DEFAULT_PROTECTED_PATTERNS.join("\n") + `
891
+ `
892
+ );
893
+ }
894
+ }
895
+
896
+ // src/retrieval/config.ts
897
+ var DEFAULT_CONFIG = {
898
+ wSeed: 0.3,
899
+ wProximity: 0.35,
900
+ wCentrality: 0.15,
901
+ wRecency: 0.1,
902
+ wCochange: 0.1,
903
+ maxDepth: 2,
904
+ topN: 20,
905
+ decayPerHop: 0.7
906
+ };
907
+ function parseSimpleYml(text) {
908
+ const out = {};
909
+ for (const line of text.split("\n")) {
910
+ const t = line.trim();
911
+ if (!t || t.startsWith("#")) continue;
912
+ const i = t.indexOf(":");
913
+ if (i === -1) continue;
914
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
915
+ }
916
+ return out;
917
+ }
918
+ function loadConfig(repoRoot2) {
919
+ const cfg = { ...DEFAULT_CONFIG };
920
+ const p = path5.join(dataDir(repoRoot2), "config.yml");
921
+ if (!fs4.existsSync(p)) return cfg;
922
+ try {
923
+ const kv = parseSimpleYml(fs4.readFileSync(p, "utf8"));
924
+ const num = (k, cur) => {
925
+ const v = Number(kv[k]);
926
+ return Number.isFinite(v) ? v : cur;
927
+ };
928
+ cfg.wSeed = num("w_seed", cfg.wSeed);
929
+ cfg.wProximity = num("w_proximity", cfg.wProximity);
930
+ cfg.wCentrality = num("w_centrality", cfg.wCentrality);
931
+ cfg.wRecency = num("w_recency", cfg.wRecency);
932
+ cfg.wCochange = num("w_cochange", cfg.wCochange);
933
+ cfg.maxDepth = Math.max(1, Math.min(4, Math.round(num("max_depth", cfg.maxDepth))));
934
+ cfg.topN = Math.max(5, Math.min(100, Math.round(num("top_n", cfg.topN))));
935
+ } catch {
936
+ }
937
+ return cfg;
938
+ }
939
+ function loadGlossary(repoRoot2) {
940
+ const out = /* @__PURE__ */ new Map();
941
+ const p = path5.join(dataDir(repoRoot2), "glossary.yml");
942
+ if (!fs4.existsSync(p)) return out;
943
+ try {
944
+ for (const line of fs4.readFileSync(p, "utf8").split("\n")) {
945
+ const t = line.trim();
946
+ if (!t || t.startsWith("#")) continue;
947
+ const i = t.indexOf(":");
948
+ if (i === -1) continue;
949
+ const term = t.slice(0, i).trim().toLowerCase();
950
+ const aliases = t.slice(i + 1).replace(/[[ \]]/g, "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
951
+ if (term && aliases.length > 0) out.set(term, aliases);
952
+ }
953
+ } catch {
954
+ }
955
+ return out;
956
+ }
957
+ function ensureDefaultFiles(repoRoot2) {
958
+ const dir = dataDir(repoRoot2);
959
+ fs4.mkdirSync(dir, { recursive: true });
960
+ const cfgPath = path5.join(dir, "config.yml");
961
+ if (!fs4.existsSync(cfgPath)) {
962
+ fs4.writeFileSync(
963
+ cfgPath,
964
+ `# ScopeCairn retrieval config (bobot FR-05, dikalibrasi Fase 6)
965
+ w_seed: 0.3
966
+ w_proximity: 0.35
967
+ w_centrality: 0.15
968
+ w_recency: 0.1
969
+ w_cochange: 0.1
970
+ max_depth: 2
971
+ top_n: 20
972
+ `
973
+ );
974
+ }
975
+ const gloPath = path5.join(dir, "glossary.yml");
976
+ if (!fs4.existsSync(gloPath)) {
977
+ fs4.writeFileSync(
978
+ gloPath,
979
+ `# Sinonim domain (opsional). Format: istilah: [alias1, alias2]
980
+ # approval: [authorize, persetujuan]
981
+ `
982
+ );
983
+ }
984
+ ensureProtectedFile(repoRoot2);
985
+ }
986
+
987
+ // src/scanner.ts
988
+ function toPosixRel(repoRoot2, abs) {
989
+ return path6.relative(repoRoot2, abs).split(path6.sep).join("/");
990
+ }
991
+ function walk(absDir, out) {
992
+ const entries = fs5.readdirSync(absDir, { withFileTypes: true });
993
+ for (const e of entries) {
994
+ const abs = path6.join(absDir, e.name);
995
+ if (e.isDirectory()) {
996
+ if (e.isSymbolicLink()) continue;
997
+ walk(abs, out);
998
+ } else if (e.isFile()) {
999
+ out.push(abs);
1000
+ }
1001
+ }
1002
+ }
1003
+ function scanRepository(repoRoot2) {
1004
+ const started = Date.now();
1005
+ const rules = loadIgnoreRules(repoRoot2);
1006
+ const db = openDb(repoRoot2);
1007
+ ensureDefaultFiles(repoRoot2);
1008
+ try {
1009
+ const allAbs = [];
1010
+ walk(repoRoot2, allAbs);
1011
+ let totalFilesSeen = 0;
1012
+ let inserted = 0;
1013
+ let updated = 0;
1014
+ let unchanged = 0;
1015
+ const seenPaths = /* @__PURE__ */ new Set();
1016
+ const getStmt = db.prepare(`SELECT id, hash FROM files WHERE path = ?`);
1017
+ const insertStmt = db.prepare(
1018
+ `INSERT INTO files(path, language, hash, size) VALUES (?, ?, ?, ?)`
1019
+ );
1020
+ const updateStmt = db.prepare(
1021
+ `UPDATE files SET hash = ?, size = ?, language = ?, updated_at = datetime('now') WHERE path = ?`
1022
+ );
1023
+ let sourceFiles = 0;
1024
+ let metaFiles = 0;
1025
+ const symbolCount = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
1026
+ let ftsCount = 0;
1027
+ try {
1028
+ ftsCount = db.prepare(`SELECT COUNT(*) AS n FROM symbol_index`).get().n;
1029
+ } catch {
1030
+ ftsCount = 0;
1031
+ }
1032
+ const backfill = symbolCount === 0 || ftsCount === 0;
1033
+ const pending = [];
1034
+ for (const abs of allAbs) {
1035
+ const rel = toPosixRel(repoRoot2, abs);
1036
+ if (isIgnored(rel, rules)) continue;
1037
+ totalFilesSeen++;
1038
+ const source = isSourceFile(abs);
1039
+ const meta = !source && isIndexableMetaFile(rel);
1040
+ if (!source && !meta) continue;
1041
+ if (source) sourceFiles++;
1042
+ else metaFiles++;
1043
+ seenPaths.add(rel);
1044
+ let buf;
1045
+ try {
1046
+ buf = fs5.readFileSync(abs);
1047
+ } catch {
1048
+ continue;
1049
+ }
1050
+ const hash = hashContent(buf);
1051
+ const language = source ? detectLanguage(abs) : "other";
1052
+ const row = getStmt.get(rel);
1053
+ if (!row) {
1054
+ const r = insertStmt.run(rel, language, hash, buf.length);
1055
+ if (source) {
1056
+ pending.push({ fileId: Number(r.lastInsertRowid), rel, content: buf.toString("utf8") });
1057
+ } else {
1058
+ indexMetaFile(db, Number(r.lastInsertRowid), rel);
1059
+ }
1060
+ inserted++;
1061
+ } else if (row.hash !== hash || backfill) {
1062
+ updateStmt.run(hash, buf.length, language, rel);
1063
+ if (source) {
1064
+ pending.push({ fileId: row.id, rel, content: buf.toString("utf8") });
1065
+ } else {
1066
+ indexMetaFile(db, row.id, rel);
1067
+ }
1068
+ updated++;
1069
+ } else {
1070
+ unchanged++;
1071
+ }
1072
+ }
1073
+ const existing = db.prepare(`SELECT path FROM files`).all();
1074
+ const delStmt = db.prepare(`DELETE FROM files WHERE path = ?`);
1075
+ let removed = 0;
1076
+ for (const r of existing) {
1077
+ if (!seenPaths.has(r.path)) {
1078
+ delStmt.run(r.path);
1079
+ removed++;
1080
+ }
1081
+ }
1082
+ const phased = pending.map((p) => ({
1083
+ ...p,
1084
+ ...indexFileSymbols(db, p.fileId, p.rel, p.content)
1085
+ }));
1086
+ for (const p of phased) {
1087
+ indexFileRelations(db, p.fileId, p.rel, p.extraction, p.fileSym);
1088
+ }
1089
+ db.prepare(
1090
+ `INSERT INTO meta(key, value) VALUES ('last_scan', datetime('now'))
1091
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
1092
+ ).run();
1093
+ linkTests(db);
1094
+ cleanupIndex(db);
1095
+ const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
1096
+ const relationships = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
1097
+ return {
1098
+ repoRoot: repoRoot2,
1099
+ totalFilesSeen,
1100
+ sourceFiles,
1101
+ metaFiles,
1102
+ symbols,
1103
+ relationships,
1104
+ inserted,
1105
+ updated,
1106
+ unchanged,
1107
+ removed,
1108
+ durationMs: Date.now() - started
1109
+ };
1110
+ } finally {
1111
+ db.close();
1112
+ }
1113
+ }
1114
+
1115
+ // src/commands/scan.ts
1116
+ function cmdScan(repoRoot2) {
1117
+ console.log("ScopeCairn");
1118
+ console.log("\u2713 Repository detected");
1119
+ const stats = scanRepository(repoRoot2);
1120
+ console.log(`\u2713 ${stats.totalFilesSeen} files found (non-ignored)`);
1121
+ console.log(`\u2713 ${stats.sourceFiles} source files (ts/tsx/js/jsx/py)`);
1122
+ if (stats.metaFiles > 0) {
1123
+ console.log(`\u2713 ${stats.metaFiles} config/schema files (json/yml/sql/prisma, file-level)`);
1124
+ }
1125
+ console.log(`\u2713 ${stats.symbols} symbols`);
1126
+ console.log(`\u2713 ${stats.relationships} relationships`);
1127
+ console.log(
1128
+ `\u2713 index: +${stats.inserted} new, ~${stats.updated} updated, =${stats.unchanged} unchanged, -${stats.removed} removed`
1129
+ );
1130
+ console.log(`\u2713 ${(stats.durationMs / 1e3).toFixed(2)}s`);
1131
+ console.log("Ready.");
1132
+ }
1133
+
1134
+ // src/commands/status.ts
1135
+ import fs6 from "fs";
1136
+ import path7 from "path";
1137
+ function cmdStatus(repoRoot2) {
1138
+ const dir = dataDir(repoRoot2);
1139
+ if (!fs6.existsSync(path7.join(dir, "scopecairn.db"))) {
1140
+ console.log("ScopeCairn");
1141
+ console.log("Status: NOT INITIALIZED (run `scopecairn scan`)");
1142
+ return;
1143
+ }
1144
+ const db = openDb(repoRoot2);
1145
+ try {
1146
+ const files = db.prepare(`SELECT COUNT(*) AS n FROM files`).get().n;
1147
+ const byLang = db.prepare(`SELECT language, COUNT(*) AS n FROM files GROUP BY language ORDER BY n DESC`).all();
1148
+ const lastScan = db.prepare(`SELECT value FROM meta WHERE key = 'last_scan'`).get()?.value ?? "-";
1149
+ console.log("ScopeCairn");
1150
+ console.log(`Index: ${dbPath(repoRoot2)}`);
1151
+ console.log(`Files indexed: ${files}`);
1152
+ for (const r of byLang) console.log(` ${r.language}: ${r.n}`);
1153
+ try {
1154
+ const symbols = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
1155
+ const rels = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
1156
+ console.log(`Symbols: ${symbols}`);
1157
+ console.log(`Relationships: ${rels}`);
1158
+ try {
1159
+ const fts = db.prepare(`SELECT COUNT(*) AS n FROM symbol_index`).get().n;
1160
+ console.log(`Symbol index (FTS5): ${fts} rows`);
1161
+ } catch {
1162
+ console.log(`Symbol index (FTS5): -`);
1163
+ }
1164
+ } catch {
1165
+ }
1166
+ console.log(`Last scan: ${lastScan}`);
1167
+ try {
1168
+ const inv = db.prepare(
1169
+ `SELECT command, COUNT(*) AS n FROM invocations GROUP BY command ORDER BY n DESC`
1170
+ ).all();
1171
+ const total = inv.reduce((a, r) => a + r.n, 0);
1172
+ if (total > 0) {
1173
+ console.log(`Invocations: ${total} (${inv.map((r) => `${r.command} ${r.n}`).join(", ")})`);
1174
+ }
1175
+ } catch {
1176
+ }
1177
+ } finally {
1178
+ db.close();
1179
+ }
1180
+ }
1181
+ function cmdClean(repoRoot2) {
1182
+ const dir = dataDir(repoRoot2);
1183
+ fs6.rmSync(dir, { recursive: true, force: true });
1184
+ console.log(`Cleaned ${dir}`);
1185
+ }
1186
+
1187
+ // src/commands/rebuild.ts
1188
+ function cmdRebuild(repoRoot2) {
1189
+ cmdClean(repoRoot2);
1190
+ cmdScan(repoRoot2);
1191
+ }
1192
+
1193
+ // src/commands/doctor.ts
1194
+ import fs8 from "fs";
1195
+ import path9 from "path";
1196
+
1197
+ // src/treesitter.ts
1198
+ var cached = null;
1199
+ async function checkTreeSitter() {
1200
+ if (cached !== null) {
1201
+ return cached ? { ok: true, detail: "cached OK" } : { ok: false, detail: "cached FAIL" };
1202
+ }
1203
+ try {
1204
+ const mod = await import("web-tree-sitter");
1205
+ const Parser = mod.Parser;
1206
+ if (!Parser || typeof Parser.init !== "function") {
1207
+ cached = false;
1208
+ return { ok: false, detail: "Parser.init not found" };
1209
+ }
1210
+ await Parser.init();
1211
+ cached = true;
1212
+ return { ok: true, detail: "WASM parser runtime OK (grammars load in Fase 2)" };
1213
+ } catch (e) {
1214
+ cached = false;
1215
+ return { ok: false, detail: e instanceof Error ? e.message : String(e) };
1216
+ }
1217
+ }
1218
+
1219
+ // src/integrate/detect.ts
1220
+ import fs7 from "fs";
1221
+ import path8 from "path";
1222
+ function detectAgentDir(repoRoot2) {
1223
+ const plural = fs7.existsSync(path8.join(repoRoot2, ".agents"));
1224
+ const singular = fs7.existsSync(path8.join(repoRoot2, ".agent"));
1225
+ if (singular && !plural) return ".agent";
1226
+ return ".agents";
1227
+ }
1228
+ function detectPrefix(repoRoot2) {
1229
+ try {
1230
+ const pkg = JSON.parse(
1231
+ fs7.readFileSync(path8.join(repoRoot2, "package.json"), "utf8")
1232
+ );
1233
+ if (pkg.dependencies?.["scopecairn"] || pkg.devDependencies?.["scopecairn"]) {
1234
+ return "npx scopecairn";
1235
+ }
1236
+ } catch {
1237
+ }
1238
+ return "scopecairn";
1239
+ }
1240
+ function extractPrefixUsed(text) {
1241
+ if (text.includes("npx scopecairn context")) return "npx scopecairn";
1242
+ if (/(^|[\s`"'`])scopecairn context/.test(text)) return "scopecairn";
1243
+ return null;
1244
+ }
1245
+
1246
+ // src/integrate/templates.ts
1247
+ var MARKER = "<!-- scopecairn:managed -->";
1248
+ function agentsMd(prefix) {
1249
+ return `${MARKER}
1250
+ # ScopeCairn
1251
+
1252
+ ## Wajib untuk setiap task coding
1253
+ Sebelum membaca file atau menjalankan grep, jalankan:
1254
+
1255
+ ${prefix} context "<permintaan user>"
1256
+
1257
+ Gunakan hasilnya sebagai titik awal. Jangan menjelajah repository dari nol.
1258
+
1259
+ ## Selama bekerja
1260
+ - Ubah hanya file di daftar Required dan Optional.
1261
+ - Jangan sentuh file di daftar Protected tanpa persetujuan user.
1262
+ - Sebelum mengubah simbol yang dipakai banyak tempat, jalankan:
1263
+ \`${prefix} impact <path>\`
1264
+ - Butuh source sebuah fungsi? Gunakan \`${prefix} read symbol <nama>\`
1265
+ alih-alih membaca file penuh.
1266
+
1267
+ ## Pengecualian
1268
+ Lewati ScopeCairn hanya untuk pertanyaan non-coding.
1269
+ `;
1270
+ }
1271
+ function skillMd(prefix) {
1272
+ return `---
1273
+ name: scopecairn
1274
+ description: Gunakan untuk SEMUA task coding (fitur baru, bug fix, refactor,
1275
+ pertanyaan tentang kode) sebelum menjelajah repository.
1276
+ ---
1277
+
1278
+ Langkah:
1279
+ 1. Jalankan \`${prefix} context "<permintaan user>"\`.
1280
+ 2. Baca bagian Relevant Files dan Task Scope.
1281
+ 3. Jika ada perubahan pada simbol bersama, jalankan \`${prefix} impact <path>\`.
1282
+ 4. Kerjakan task sesuai scope, lalu berhenti saat selesai.
1283
+ `;
1284
+ }
1285
+ function workflowMd(prefix) {
1286
+ return `# /scopecairn
1287
+
1288
+ Shortcut manual bila agent lupa memanggil ScopeCairn otomatis.
1289
+
1290
+ 1. Jalankan \`${prefix} context "<permintaan user>"\`.
1291
+ 2. Ikuti Relevant Files dan Task Scope dari output.
1292
+ 3. Perlu dampak perubahan? \`${prefix} impact <path>\`.
1293
+ 4. Perlu source simbol? \`${prefix} read symbol <nama>\`.
1294
+ `;
1295
+ }
1296
+ var ALLOWLIST_COMMANDS = [
1297
+ "context",
1298
+ "impact",
1299
+ "read",
1300
+ "query",
1301
+ "graph",
1302
+ "status",
1303
+ "doctor"
1304
+ ];
1305
+ function allowlistGuide(prefix) {
1306
+ const lines = ALLOWLIST_COMMANDS.map((c) => ` ${prefix} ${c}`);
1307
+ return `Agar agent dapat memakai ScopeCairn tanpa persetujuan per panggilan,
1308
+ tambahkan entri berikut ke Terminal Allow List Antigravity
1309
+ (Settings \u2192 Advanced Settings \u2192 Terminal):
1310
+
1311
+ ` + lines.join("\n") + `
1312
+
1313
+ Catatan: ScopeCairn tidak mengubah pengaturan IDE Anda secara otomatis.`;
1314
+ }
1315
+
1316
+ // src/commands/doctor.ts
1317
+ function tick(ok) {
1318
+ return ok ? "\u2713" : "\u2717";
1319
+ }
1320
+ async function cmdDoctor(repoRoot2) {
1321
+ const nodeOk = Number(process.versions.node.split(".")[0]) >= 20;
1322
+ console.log(`${tick(nodeOk)} Node.js ${process.version} (need >=20)`);
1323
+ let sqliteOk = false;
1324
+ try {
1325
+ const { createRequire: createRequire2 } = await import("module");
1326
+ const req = createRequire2(import.meta.url);
1327
+ const m = req("node:sqlite");
1328
+ sqliteOk = typeof m.DatabaseSync === "function";
1329
+ } catch {
1330
+ sqliteOk = false;
1331
+ }
1332
+ console.log(`${tick(sqliteOk)} SQLite (node:sqlite built-in)`);
1333
+ const ts = await checkTreeSitter();
1334
+ console.log(`${tick(ts.ok)} Tree-sitter ${ts.detail}`);
1335
+ let dbOk = false;
1336
+ let graphDetail = "";
1337
+ try {
1338
+ const db = openDb(repoRoot2);
1339
+ db.prepare(`SELECT 1`).get();
1340
+ const s = db.prepare(`SELECT COUNT(*) AS n FROM symbols`).get().n;
1341
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships`).get().n;
1342
+ db.close();
1343
+ dbOk = fs8.existsSync(dbPath(repoRoot2));
1344
+ graphDetail = ` (${s} symbols, ${r} rels)`;
1345
+ } catch {
1346
+ dbOk = false;
1347
+ }
1348
+ console.log(`${tick(dbOk)} Database ${dbPath(repoRoot2)}${graphDetail}`);
1349
+ const protPath = path9.join(dataDir(repoRoot2), "protected.yml");
1350
+ console.log(`${tick(fs8.existsSync(protPath))} Protected ${protPath}`);
1351
+ const expectedPrefix = detectPrefix(repoRoot2);
1352
+ const agentDir = detectAgentDir(repoRoot2);
1353
+ const agentsPath = path9.join(repoRoot2, "AGENTS.md");
1354
+ let agentsOk = false;
1355
+ let agentsPrefix = null;
1356
+ if (fs8.existsSync(agentsPath)) {
1357
+ const text = fs8.readFileSync(agentsPath, "utf8");
1358
+ agentsOk = text.includes(MARKER);
1359
+ agentsPrefix = extractPrefixUsed(text);
1360
+ }
1361
+ console.log(`${tick(agentsOk)} AGENTS.md (${agentsOk ? "blok ScopeCairn ada" : "hilang \u2014 jalankan `scopecairn init`"})`);
1362
+ const skillPath = path9.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
1363
+ const skillOk = fs8.existsSync(skillPath);
1364
+ console.log(`${tick(skillOk)} Skill (${agentDir}/skills/scopecairn/SKILL.md)`);
1365
+ const wfPath = path9.join(repoRoot2, "workflows", "scopecairn.md");
1366
+ console.log(`${tick(fs8.existsSync(wfPath))} Workflow (workflows/scopecairn.md, opsional)`);
1367
+ let prefixOk = true;
1368
+ if (agentsOk && agentsPrefix && agentsPrefix !== expectedPrefix) {
1369
+ prefixOk = false;
1370
+ console.log(
1371
+ `\u2717 Prefix tak konsisten: AGENTS.md memakai \`${agentsPrefix}\`, instalasi terdeteksi \`${expectedPrefix}\`. Jalankan ulang \`scopecairn init\`.`
1372
+ );
1373
+ } else if (agentsOk) {
1374
+ console.log(`\u2713 Prefix konsisten (\`${expectedPrefix} context\`)`);
1375
+ }
1376
+ console.log(`i Strict Mode mengabaikan allowlist \u2014 semua perintah tetap perlu persetujuan.`);
1377
+ const healthy = nodeOk && sqliteOk && ts.ok && dbOk && agentsOk && skillOk && prefixOk;
1378
+ console.log(`Status: ${healthy ? "HEALTHY" : "DEGRADED (see \u2717 above)"}`);
1379
+ }
1380
+
1381
+ // src/invocations.ts
1382
+ function logInvocation(db, command, taskId) {
1383
+ try {
1384
+ db.prepare(`INSERT INTO invocations(task_id, command) VALUES (?, ?)`).run(
1385
+ taskId ?? null,
1386
+ command
1387
+ );
1388
+ } catch {
1389
+ }
1390
+ }
1391
+
1392
+ // src/graph.ts
1393
+ function asNode(r) {
1394
+ return {
1395
+ id: r["id"],
1396
+ name: r["name"],
1397
+ type: r["type"],
1398
+ file: r["file"] ?? ""
1399
+ };
1400
+ }
1401
+ function findSymbols(db, key) {
1402
+ const byName = db.prepare(
1403
+ `SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
1404
+ JOIN files f ON f.id = s.file_id WHERE s.name = ? LIMIT 20`
1405
+ ).all(key);
1406
+ if (byName.length > 0) return byName.map(asNode);
1407
+ const like = db.prepare(
1408
+ `SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
1409
+ JOIN files f ON f.id = s.file_id
1410
+ WHERE s.name LIKE ? OR f.path LIKE ? LIMIT 20`
1411
+ ).all(`%${key}%`, `%${key}%`);
1412
+ return like.map(asNode);
1413
+ }
1414
+ function neighbors(db, id, limit = 50) {
1415
+ const out = db.prepare(
1416
+ `SELECT r.relationship_type AS rel, r.weight, r.confidence,
1417
+ t.id, t.name, t.type, f.path AS file
1418
+ FROM relationships r JOIN symbols t ON t.id = r.target_id
1419
+ JOIN files f ON f.id = t.file_id
1420
+ WHERE r.source_id = ? LIMIT ${limit}`
1421
+ ).all(id);
1422
+ const inn = db.prepare(
1423
+ `SELECT r.relationship_type AS rel, r.weight, r.confidence,
1424
+ s.id, s.name, s.type, f.path AS file
1425
+ FROM relationships r JOIN symbols s ON s.id = r.source_id
1426
+ JOIN files f ON f.id = s.file_id
1427
+ WHERE r.target_id = ? LIMIT ${limit}`
1428
+ ).all(id);
1429
+ return [
1430
+ ...out.map((r) => ({
1431
+ rel: r["rel"],
1432
+ weight: r["weight"],
1433
+ confidence: r["confidence"],
1434
+ other: asNode(r),
1435
+ direction: "out"
1436
+ })),
1437
+ ...inn.map((r) => ({
1438
+ rel: r["rel"],
1439
+ weight: r["weight"],
1440
+ confidence: r["confidence"],
1441
+ other: asNode(r),
1442
+ direction: "in"
1443
+ }))
1444
+ ];
1445
+ }
1446
+
1447
+ // src/commands/graph.ts
1448
+ function cmdGraph(repoRoot2, key) {
1449
+ const db = openDb(repoRoot2);
1450
+ try {
1451
+ logInvocation(db, "graph");
1452
+ const hits = findSymbols(db, key);
1453
+ if (hits.length === 0) {
1454
+ console.log(`No symbol or file matching "${key}".`);
1455
+ return;
1456
+ }
1457
+ const node = hits.find((h) => h.name === key) ?? hits[0];
1458
+ if (hits.length > 1) {
1459
+ console.log(`Matches: ${hits.map((h) => `${h.name} (${h.type}, ${h.file})`).join(" | ")}`);
1460
+ console.log(`Showing: ${node.name} (${node.type}, ${node.file})`);
1461
+ console.log("");
1462
+ } else {
1463
+ console.log(`${node.name} (${node.type}) \u2014 ${node.file}`);
1464
+ console.log("");
1465
+ }
1466
+ const edges = neighbors(db, node.id);
1467
+ if (edges.length === 0) {
1468
+ console.log("(no relations)");
1469
+ return;
1470
+ }
1471
+ const out = edges.filter((e) => e.direction === "out");
1472
+ const inn = edges.filter((e) => e.direction === "in");
1473
+ if (out.length > 0) {
1474
+ console.log("Outgoing:");
1475
+ for (const e of out.slice(0, 30)) {
1476
+ console.log(
1477
+ ` ${e.rel} \u2192 ${e.other.name} (${e.other.type}, ${e.other.file}) [w=${e.weight} c=${e.confidence}]`
1478
+ );
1479
+ }
1480
+ }
1481
+ if (inn.length > 0) {
1482
+ console.log("Incoming:");
1483
+ for (const e of inn.slice(0, 30)) {
1484
+ console.log(
1485
+ ` ${e.rel} \u2190 ${e.other.name} (${e.other.type}, ${e.other.file}) [w=${e.weight} c=${e.confidence}]`
1486
+ );
1487
+ }
1488
+ }
1489
+ } finally {
1490
+ db.close();
1491
+ }
1492
+ }
1493
+
1494
+ // src/commands/query.ts
1495
+ function cmdQuery(repoRoot2, text) {
1496
+ const db = openDb(repoRoot2);
1497
+ try {
1498
+ logInvocation(db, "query");
1499
+ const rows = db.prepare(
1500
+ `SELECT s.name, s.type, s.signature, f.path AS file
1501
+ FROM symbols s JOIN files f ON f.id = s.file_id
1502
+ WHERE s.name LIKE ? OR f.path LIKE ? OR s.signature LIKE ?
1503
+ ORDER BY CASE WHEN s.name = ? THEN 0 WHEN s.name LIKE ? THEN 1 ELSE 2 END,
1504
+ s.name LIMIT 30`
1505
+ ).all(`%${text}%`, `%${text}%`, `%${text}%`, text, `${text}%`);
1506
+ if (rows.length === 0) {
1507
+ console.log(`No matches for "${text}".`);
1508
+ return;
1509
+ }
1510
+ for (const r of rows) {
1511
+ console.log(`${r.name} (${r.type}) \u2014 ${r.file}${r.signature ? ` \u2014 ${r.signature.slice(0, 80)}` : ""}`);
1512
+ }
1513
+ } finally {
1514
+ db.close();
1515
+ }
1516
+ }
1517
+
1518
+ // src/commands/read.ts
1519
+ import fs9 from "fs";
1520
+ import path10 from "path";
1521
+ function cmdRead(repoRoot2, kind, name) {
1522
+ if (kind !== "symbol") {
1523
+ console.log(`Unknown read kind "${kind}". Usage: scopecairn read symbol <nama>`);
1524
+ return;
1525
+ }
1526
+ const db = openDb(repoRoot2);
1527
+ try {
1528
+ logInvocation(db, "read");
1529
+ const hits = findSymbols(db, name);
1530
+ const node = hits.find((h) => h.name === name && h.type !== "file") ?? hits.find((h) => h.type !== "file") ?? hits[0];
1531
+ if (!node) {
1532
+ console.log(`No symbol matching "${name}".`);
1533
+ return;
1534
+ }
1535
+ const row = db.prepare(`SELECT start_line, end_line FROM symbols WHERE id = ?`).get(node.id);
1536
+ const abs = path10.join(repoRoot2, node.file);
1537
+ let lines;
1538
+ try {
1539
+ lines = fs9.readFileSync(abs, "utf8").split("\n");
1540
+ } catch {
1541
+ console.log(`Cannot read ${node.file}.`);
1542
+ return;
1543
+ }
1544
+ const start = Math.max(1, row.start_line - 1);
1545
+ const end = Math.min(lines.length, Math.max(row.end_line, row.start_line + 40));
1546
+ console.log(`# ${node.name} (${node.type}) \u2014 ${node.file}:${start}-${end}`);
1547
+ console.log(lines.slice(start - 1, end).join("\n"));
1548
+ } finally {
1549
+ db.close();
1550
+ }
1551
+ }
1552
+
1553
+ // src/refresh.ts
1554
+ var BULK_THRESHOLD = 50;
1555
+ function refreshIndex(repoRoot2) {
1556
+ const stats = scanRepository(repoRoot2);
1557
+ const bulk = stats.inserted + stats.updated > BULK_THRESHOLD;
1558
+ return { stats, bulk };
1559
+ }
1560
+
1561
+ // src/retrieval/git.ts
1562
+ import { execFileSync } from "child_process";
1563
+ function runGit(repoRoot2, args) {
1564
+ try {
1565
+ return execFileSync("git", args, {
1566
+ cwd: repoRoot2,
1567
+ encoding: "utf8",
1568
+ timeout: 8e3,
1569
+ stdio: ["ignore", "pipe", "ignore"]
1570
+ });
1571
+ } catch {
1572
+ return null;
1573
+ }
1574
+ }
1575
+ function gitSignals(repoRoot2, seedFiles, maxCommits = 120) {
1576
+ const empty = { recency: /* @__PURE__ */ new Map(), cochange: /* @__PURE__ */ new Map(), available: false };
1577
+ const log = runGit(repoRoot2, [
1578
+ "log",
1579
+ `--max-count=${maxCommits}`,
1580
+ "--name-only",
1581
+ "--format=COMMIT:%h"
1582
+ ]);
1583
+ if (!log) return empty;
1584
+ const commits = [];
1585
+ let cur = [];
1586
+ for (const line of log.split("\n")) {
1587
+ const t = line.trim();
1588
+ if (t.startsWith("COMMIT:")) {
1589
+ if (cur.length > 0) commits.push(cur);
1590
+ cur = [];
1591
+ } else if (t) {
1592
+ cur.push(t);
1593
+ }
1594
+ }
1595
+ if (cur.length > 0) commits.push(cur);
1596
+ if (commits.length === 0) return empty;
1597
+ const recency = /* @__PURE__ */ new Map();
1598
+ commits.forEach((files, i) => {
1599
+ const score = 1 - i / commits.length;
1600
+ for (const f of files) {
1601
+ if (!recency.has(f)) recency.set(f, score);
1602
+ }
1603
+ });
1604
+ const seedSet = new Set(seedFiles);
1605
+ const partner = /* @__PURE__ */ new Map();
1606
+ let seedCommits = 0;
1607
+ for (const files of commits) {
1608
+ if (files.some((f) => seedSet.has(f))) {
1609
+ seedCommits++;
1610
+ for (const f of files) {
1611
+ if (!seedSet.has(f)) partner.set(f, (partner.get(f) ?? 0) + 1);
1612
+ }
1613
+ }
1614
+ }
1615
+ const cochange = /* @__PURE__ */ new Map();
1616
+ if (seedCommits > 0) {
1617
+ for (const [f, n] of partner) cochange.set(f, n / seedCommits);
1618
+ }
1619
+ return { recency, cochange, available: true };
1620
+ }
1621
+
1622
+ // src/retrieval/retrieve.ts
1623
+ function expandGlossary(tokens, glossary) {
1624
+ const out = new Set(tokens);
1625
+ for (const t of tokens) {
1626
+ for (const a of glossary.get(t) ?? []) out.add(a);
1627
+ for (const [term, aliases] of glossary) {
1628
+ if (aliases.includes(t)) out.add(term);
1629
+ }
1630
+ }
1631
+ return [...out];
1632
+ }
1633
+ function retrieve(db, repoRoot2, task, opts) {
1634
+ const config = loadConfig(repoRoot2);
1635
+ if (opts?.topN) config.topN = opts.topN;
1636
+ if (opts?.maxDepth) config.maxDepth = opts.maxDepth;
1637
+ if (opts?.weights) Object.assign(config, opts.weights);
1638
+ const baseTokens = tokenizeTask(task);
1639
+ const tokens = expandGlossary(baseTokens, loadGlossary(repoRoot2));
1640
+ const seeds = seedSearch(db, tokens);
1641
+ const seedScore = new Map(seeds.map((s) => [s.symbolId, s.bm25]));
1642
+ const proximity = /* @__PURE__ */ new Map();
1643
+ const visited = /* @__PURE__ */ new Set();
1644
+ let frontier = seeds.map((s) => s.symbolId);
1645
+ seeds.forEach((s) => {
1646
+ proximity.set(s.symbolId, Math.max(proximity.get(s.symbolId) ?? 0, s.bm25));
1647
+ visited.add(s.symbolId);
1648
+ });
1649
+ const edgeStmt = db.prepare(
1650
+ `SELECT source_id, target_id, weight FROM relationships`
1651
+ );
1652
+ const adj = /* @__PURE__ */ new Map();
1653
+ for (const r of edgeStmt.all()) {
1654
+ if (!adj.has(r.source_id)) adj.set(r.source_id, []);
1655
+ adj.get(r.source_id).push({ to: r.target_id, w: r.weight });
1656
+ if (!adj.has(r.target_id)) adj.set(r.target_id, []);
1657
+ adj.get(r.target_id).push({ to: r.source_id, w: r.weight * 0.8 });
1658
+ }
1659
+ let hopScore = 1;
1660
+ for (let depth = 1; depth <= config.maxDepth; depth++) {
1661
+ hopScore *= config.decayPerHop;
1662
+ const next = [];
1663
+ for (const id of frontier) {
1664
+ const base = proximity.get(id) ?? 0;
1665
+ for (const e of adj.get(id) ?? []) {
1666
+ const cand = base * e.w * hopScore;
1667
+ if (cand > (proximity.get(e.to) ?? 0)) proximity.set(e.to, cand);
1668
+ if (!visited.has(e.to)) {
1669
+ visited.add(e.to);
1670
+ next.push(e.to);
1671
+ }
1672
+ }
1673
+ }
1674
+ frontier = next;
1675
+ if (frontier.length === 0) break;
1676
+ }
1677
+ const fanin = /* @__PURE__ */ new Map();
1678
+ let maxFan = 1;
1679
+ for (const r of db.prepare(`SELECT target_id AS id, COUNT(*) AS n FROM relationships GROUP BY target_id`).all()) {
1680
+ fanin.set(r.id, r.n);
1681
+ if (r.n > maxFan) maxFan = r.n;
1682
+ }
1683
+ const seedFiles = /* @__PURE__ */ new Set();
1684
+ if (seeds.length > 0) {
1685
+ const ph = seeds.map(() => "?").join(",");
1686
+ for (const r of db.prepare(`SELECT DISTINCT f.path AS p FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id IN (${ph})`).all(...seeds.map((s) => s.symbolId))) {
1687
+ seedFiles.add(r.p);
1688
+ }
1689
+ }
1690
+ const git = gitSignals(repoRoot2, [...seedFiles]);
1691
+ const meta = /* @__PURE__ */ new Map();
1692
+ if (visited.size > 0) {
1693
+ const ph = [...visited].map(() => "?").join(",");
1694
+ for (const r of db.prepare(
1695
+ `SELECT s.id, s.name, s.type, s.signature, f.path AS file FROM symbols s
1696
+ JOIN files f ON f.id = s.file_id WHERE s.id IN (${ph})`
1697
+ ).all(...[...visited])) {
1698
+ meta.set(r.id, r);
1699
+ }
1700
+ }
1701
+ const ranked = [];
1702
+ let skippedFiles = 0;
1703
+ for (const [id, prox] of proximity) {
1704
+ const m = meta.get(id);
1705
+ if (!m) continue;
1706
+ if (m.type === "file") {
1707
+ skippedFiles++;
1708
+ continue;
1709
+ }
1710
+ const seed = seedScore.get(id) ?? 0;
1711
+ const centrality = (fanin.get(id) ?? 0) / maxFan;
1712
+ const recency = git.recency.get(m.file) ?? 0;
1713
+ const cochange = git.cochange.get(m.file) ?? 0;
1714
+ const score = config.wSeed * seed + config.wProximity * Math.min(1, prox) + config.wCentrality * centrality + config.wRecency * recency + config.wCochange * cochange;
1715
+ const why = [];
1716
+ if (seed > 0.05) why.push("seed");
1717
+ if (prox > 0.05 && seed <= 0.05) why.push("graph");
1718
+ if (centrality > 0.3) why.push("central");
1719
+ if (recency > 0.5) why.push("recent");
1720
+ if (cochange > 0.3) why.push("co-change");
1721
+ ranked.push({
1722
+ id,
1723
+ name: m.name,
1724
+ type: m.type,
1725
+ file: m.file,
1726
+ signature: m.signature,
1727
+ score,
1728
+ parts: { seed, proximity: Math.min(1, prox), centrality, recency, cochange },
1729
+ reason: why.join("+") || "graph"
1730
+ });
1731
+ }
1732
+ ranked.sort((a, b) => b.score - a.score);
1733
+ if (ranked.length === 0 && skippedFiles > 0) {
1734
+ const fileSeeds = seeds.map((s) => ({ id: s.symbolId, seed: s.bm25, m: meta.get(s.symbolId) })).filter((s) => s.m && s.m.type === "file").sort((a, b) => b.seed - a.seed).slice(0, 5);
1735
+ for (const f of fileSeeds) {
1736
+ ranked.push({
1737
+ id: f.id,
1738
+ name: f.m.name,
1739
+ type: "file",
1740
+ file: f.m.file,
1741
+ signature: "",
1742
+ score: config.wSeed * f.seed + 0.05,
1743
+ parts: { seed: f.seed, proximity: 0, centrality: 0, recency: 0, cochange: 0 },
1744
+ reason: "seed"
1745
+ });
1746
+ }
1747
+ }
1748
+ return { tokens, seeds: seeds.map((s) => s.symbolId), ranked: ranked.slice(0, config.topN), config, gitAvailable: git.available };
1749
+ }
1750
+
1751
+ // src/retrieval/classify.ts
1752
+ var FANIN_THRESHOLD = 5;
1753
+ function moduleOf(file) {
1754
+ const i = file.indexOf("/");
1755
+ return i === -1 ? "." : file.slice(0, i);
1756
+ }
1757
+ function classify(ranked, faninOf, threshold = 0.15) {
1758
+ const relevant = ranked.filter((r) => r.score >= threshold);
1759
+ const files = [...new Set(relevant.map((r) => r.file))];
1760
+ const modules = [...new Set(files.map(moduleOf))];
1761
+ const reasons = [];
1762
+ const DEFINITIONAL = /* @__PURE__ */ new Set([
1763
+ "function",
1764
+ "class",
1765
+ "method",
1766
+ "component",
1767
+ "interface",
1768
+ "type"
1769
+ ]);
1770
+ const highFanin = relevant.filter((r) => DEFINITIONAL.has(r.type) && faninOf(r.id) >= FANIN_THRESHOLD).map((r) => r.name);
1771
+ const protectedHits = files.filter((f) => isProtected(f));
1772
+ let complexity = "SIMPLE";
1773
+ if (files.length >= 3) {
1774
+ complexity = "COMPLEX";
1775
+ reasons.push(`estimasi ${files.length} file (\u22653)`);
1776
+ }
1777
+ if (modules.length > 1) {
1778
+ complexity = "COMPLEX";
1779
+ reasons.push(`lintas modul (${modules.join(", ")})`);
1780
+ }
1781
+ if (highFanin.length > 0) {
1782
+ complexity = "COMPLEX";
1783
+ reasons.push(`simbol dipakai banyak tempat: ${highFanin.slice(0, 3).join(", ")}`);
1784
+ }
1785
+ if (protectedHits.length > 0) {
1786
+ complexity = "COMPLEX";
1787
+ reasons.push(`menyentuh area sensitif: ${protectedHits.slice(0, 3).join(", ")}`);
1788
+ }
1789
+ if (complexity === "SIMPLE") reasons.push(`estimasi ${files.length} file, 1 modul`);
1790
+ return {
1791
+ complexity,
1792
+ estimatedFiles: files.length,
1793
+ modules,
1794
+ reasons,
1795
+ highFanin,
1796
+ protectedHits
1797
+ };
1798
+ }
1799
+
1800
+ // src/scope/scope.ts
1801
+ function computeScope(highFiles, mediumFiles, testFiles, patterns) {
1802
+ const required = [];
1803
+ const optional = [];
1804
+ const protectedFiles = [];
1805
+ const seen = /* @__PURE__ */ new Set();
1806
+ const place = (f) => {
1807
+ if (seen.has(f)) return;
1808
+ seen.add(f);
1809
+ if (isProtected(f, patterns)) protectedFiles.push(f);
1810
+ else if (highFiles.includes(f)) required.push(f);
1811
+ else optional.push(f);
1812
+ };
1813
+ for (const f of highFiles) place(f);
1814
+ for (const f of mediumFiles) place(f);
1815
+ for (const t of testFiles) {
1816
+ if (seen.has(t)) continue;
1817
+ seen.add(t);
1818
+ if (isProtected(t, patterns)) protectedFiles.push(t);
1819
+ else optional.push(t);
1820
+ }
1821
+ return { required, optional, protected: protectedFiles };
1822
+ }
1823
+ function scopeBlock(scope) {
1824
+ const fmt = (xs) => xs.length > 0 ? xs.join(", ") : "-";
1825
+ return `## Scope
1826
+ Required: ${fmt(scope.required)}
1827
+ Optional: ${fmt(scope.optional)}
1828
+ Protected: ${fmt(scope.protected)}
1829
+ `;
1830
+ }
1831
+
1832
+ // src/scope/guard.ts
1833
+ var TASK_GUARD_RULES = [
1834
+ "Ubah hanya file Required dan Optional; Protected perlu persetujuan user.",
1835
+ "Jangan refactor, upgrade dependency, atau abstraksi prematur yang tak diminta.",
1836
+ "Jangan tulis ulang kode berjalan; jangan tambah file/komentar tak perlu.",
1837
+ "Berhenti saat acceptance criteria terpenuhi."
1838
+ ];
1839
+ var DEFINITION_OF_DONE = [
1840
+ "Fungsionalitas yang diminta terimplementasi",
1841
+ "Test relevan lulus",
1842
+ "Tidak ada file berubah tanpa alasan",
1843
+ "Tidak ada refactoring tak terkait",
1844
+ "Acceptance criteria terpenuhi"
1845
+ ];
1846
+ function guardBlock() {
1847
+ return `## Task Guard
1848
+ ${TASK_GUARD_RULES.map((r) => `- ${r}`).join("\n")}
1849
+
1850
+ ## Definition of Done
1851
+ ${DEFINITION_OF_DONE.map((d) => `- [ ] ${d}`).join("\n")}
1852
+ `;
1853
+ }
1854
+
1855
+ // src/retrieval/contextBuilder.ts
1856
+ function buildContext(db, task, result, cls, opts) {
1857
+ const complex = cls.complexity === "COMPLEX" || opts?.escalate === true;
1858
+ if (!complex) {
1859
+ const files = [];
1860
+ for (const r of result.ranked) {
1861
+ if (!files.includes(r.file)) files.push(r.file);
1862
+ if (files.length >= 3) break;
1863
+ }
1864
+ const cand = files.length > 0 ? files.map((f) => `\`${f}\``).join(", ") : "pencarian langsung (tidak ada simbol cocok di index)";
1865
+ return {
1866
+ markdown: `# Task Context
1867
+
1868
+ ## Task
1869
+ ${task}
1870
+
1871
+ ## Complexity
1872
+ SIMPLE (${cls.reasons.join("; ")})
1873
+
1874
+ Task sederhana. Gunakan pencarian langsung. Kandidat: ${cand}
1875
+ `,
1876
+ high: [],
1877
+ medium: [],
1878
+ tests: [],
1879
+ dependencies: []
1880
+ };
1881
+ }
1882
+ const ranked = result.ranked;
1883
+ const hi = ranked.filter((r) => r.score >= 0.3).slice(0, 10);
1884
+ const med = ranked.filter((r) => r.score >= 0.15 && r.score < 0.3).slice(0, 10);
1885
+ const highFiles = [...new Set(hi.map((r) => r.file))];
1886
+ const medFiles = [...new Set(med.map((r) => r.file))].filter((f) => !highFiles.includes(f));
1887
+ const deps = [];
1888
+ if (highFiles.length > 0) {
1889
+ const ph = highFiles.map(() => "?").join(",");
1890
+ try {
1891
+ const rows = db.prepare(
1892
+ `SELECT DISTINCT f1.path AS a, f2.path AS b
1893
+ FROM relationships r
1894
+ JOIN symbols s1 ON s1.id = r.source_id
1895
+ JOIN symbols s2 ON s2.id = r.target_id
1896
+ JOIN files f1 ON f1.id = s1.file_id
1897
+ JOIN files f2 ON f2.id = s2.file_id
1898
+ WHERE r.relationship_type IN ('IMPORTS','CALLS')
1899
+ AND f1.path IN (${ph}) AND f2.path IN (${ph}) AND f1.path != f2.path
1900
+ LIMIT 10`
1901
+ ).all(...highFiles, ...highFiles);
1902
+ for (const r of rows) deps.push(`${r.a} \u2192 ${r.b}`);
1903
+ } catch {
1904
+ }
1905
+ }
1906
+ const testSet = /* @__PURE__ */ new Set();
1907
+ if (highFiles.length > 0) {
1908
+ const ph = highFiles.map(() => "?").join(",");
1909
+ try {
1910
+ const rows = db.prepare(
1911
+ `SELECT DISTINCT f2.path AS t
1912
+ FROM relationships r
1913
+ JOIN symbols s1 ON s1.id = r.source_id
1914
+ JOIN symbols s2 ON s2.id = r.target_id
1915
+ JOIN files f1 ON f1.id = s1.file_id
1916
+ JOIN files f2 ON f2.id = s2.file_id
1917
+ WHERE r.relationship_type = 'TESTS'
1918
+ AND (f1.path IN (${ph}) OR f2.path IN (${ph}))`
1919
+ ).all(...highFiles, ...highFiles);
1920
+ for (const r of rows) testSet.add(r.t);
1921
+ } catch {
1922
+ }
1923
+ }
1924
+ for (const r of ranked) {
1925
+ if (/(test|spec)/i.test(r.file)) testSet.add(r.file);
1926
+ }
1927
+ const tests = [...testSet].slice(0, 10);
1928
+ const symLine = (r) => `- ${r.name} (${r.type}) \u2014 \`${r.file}\` [${r.score.toFixed(2)} ${r.reason}]`;
1929
+ const scope = computeScope(
1930
+ highFiles,
1931
+ medFiles,
1932
+ tests,
1933
+ loadProtectedPatterns(opts?.repoRoot ?? ".")
1934
+ );
1935
+ const md = `# Task Context
1936
+
1937
+ ## Task
1938
+ ${task}
1939
+
1940
+ ## Complexity
1941
+ ${cls.complexity} (${cls.reasons.join("; ")})
1942
+
1943
+ ## Relevant Files
1944
+ ### HIGH
1945
+ ${highFiles.map((f) => `- ${f}`).join("\n") || "-"}
1946
+ ### MEDIUM
1947
+ ${medFiles.map((f) => `- ${f}`).join("\n") || "-"}
1948
+
1949
+ ## Key Symbols
1950
+ ${hi.slice(0, 8).map(symLine).join("\n") || "-"}
1951
+
1952
+ ## Dependencies
1953
+ ${deps.map((d) => `- ${d}`).join("\n") || "-"}
1954
+
1955
+ ## Related Tests
1956
+ ${tests.map((t) => `- ${t}`).join("\n") || "-"}
1957
+
1958
+ ` + scopeBlock(scope) + `
1959
+ ` + guardBlock();
1960
+ return { markdown: md, high: highFiles, medium: medFiles, tests, dependencies: deps };
1961
+ }
1962
+
1963
+ // src/commands/context.ts
1964
+ function cmdContext(repoRoot2, task, opts = {}) {
1965
+ if (!opts.noRefresh) {
1966
+ const { stats, bulk } = refreshIndex(repoRoot2);
1967
+ if (bulk) {
1968
+ console.log(
1969
+ `> Note: ${stats.inserted + stats.updated} file berubah (mis. git pull/ganti branch). Konteks tetap dibuat; jalankan \`scopecairn scan\` bila hasil terasa usang.
1970
+ `
1971
+ );
1972
+ }
1973
+ }
1974
+ const db = openDb(repoRoot2);
1975
+ try {
1976
+ const fileCount = db.prepare(`SELECT COUNT(*) AS n FROM files`).get().n;
1977
+ if (fileCount === 0) {
1978
+ console.log("Index kosong. Jalankan `scopecairn scan` dulu.");
1979
+ return;
1980
+ }
1981
+ const result = retrieve(db, repoRoot2, task);
1982
+ if (result.ranked.length === 0) {
1983
+ logInvocation(db, "context");
1984
+ console.log(
1985
+ `# Task Context
1986
+
1987
+ ## Task
1988
+ ${task}
1989
+
1990
+ ## Complexity
1991
+ SIMPLE (tidak ada simbol cocok di index)
1992
+
1993
+ Task sederhana. Gunakan pencarian langsung. Bila istilah domain tak cocok dengan nama kode, tambah alias di \`.scopecairn/glossary.yml\`.
1994
+ `
1995
+ );
1996
+ return;
1997
+ }
1998
+ const faninOf = (id) => {
1999
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM relationships WHERE target_id = ?`).get(id);
2000
+ return r.n;
2001
+ };
2002
+ const cls = classify(result.ranked, faninOf);
2003
+ const t = db.prepare(`INSERT INTO tasks(description, complexity) VALUES (?, ?)`).run(task, opts.escalate ? "COMPLEX" : cls.complexity);
2004
+ const taskId = Number(t.lastInsertRowid);
2005
+ const insCtx = db.prepare(
2006
+ `INSERT INTO task_context(task_id, symbol_id, score, reason) VALUES (?, ?, ?, ?)`
2007
+ );
2008
+ for (const r of result.ranked.slice(0, 20)) {
2009
+ insCtx.run(taskId, r.id, r.score, r.reason);
2010
+ }
2011
+ logInvocation(db, "context", taskId);
2012
+ const built = buildContext(db, task, result, cls, {
2013
+ escalate: opts.escalate,
2014
+ repoRoot: repoRoot2
2015
+ });
2016
+ console.log(built.markdown);
2017
+ } finally {
2018
+ db.close();
2019
+ }
2020
+ }
2021
+
2022
+ // src/commands/impact.ts
2023
+ function cmdImpact(repoRoot2, target, opts = {}) {
2024
+ if (!opts.noRefresh) refreshIndex(repoRoot2);
2025
+ const db = openDb(repoRoot2);
2026
+ try {
2027
+ logInvocation(db, "impact");
2028
+ let seeds = findSymbols(db, target);
2029
+ if (seeds.length === 0) {
2030
+ const rows = db.prepare(
2031
+ `SELECT s.id, s.name, s.type, f.path AS file FROM symbols s
2032
+ JOIN files f ON f.id = s.file_id WHERE f.path LIKE ? LIMIT 20`
2033
+ ).all(`%${target}`);
2034
+ seeds = rows;
2035
+ }
2036
+ if (seeds.length === 0) {
2037
+ console.log(`Tidak ada file/simbol yang cocok dengan "${target}".`);
2038
+ return;
2039
+ }
2040
+ const fileHit = seeds.find((s) => s.type === "file");
2041
+ const center = fileHit ?? seeds[0];
2042
+ const centerLabel = fileHit != null ? `${center.file}` : `${center.name} (${center.type}, ${center.file})`;
2043
+ const direct = neighbors(db, center.id);
2044
+ const directIds = new Set(direct.map((e) => e.other.id));
2045
+ const indirect = /* @__PURE__ */ new Map();
2046
+ for (const e of direct.slice(0, 20)) {
2047
+ for (const e2 of neighbors(db, e.other.id, 20)) {
2048
+ if (e2.other.id === center.id || directIds.has(e2.other.id)) continue;
2049
+ if (!indirect.has(e2.other.id)) {
2050
+ indirect.set(e2.other.id, {
2051
+ name: e2.other.name,
2052
+ type: e2.other.type,
2053
+ file: e2.other.file,
2054
+ via: e.other.name,
2055
+ rel: e2.rel
2056
+ });
2057
+ }
2058
+ if (indirect.size >= 15) break;
2059
+ }
2060
+ if (indirect.size >= 15) break;
2061
+ }
2062
+ const fmtFile = (f) => isProtected(f, loadProtectedPatterns(repoRoot2)) ? `${f} \u26A0 Protected` : f;
2063
+ const byRel = (rel, dir) => {
2064
+ const seen = /* @__PURE__ */ new Set();
2065
+ const out = [];
2066
+ for (const e of direct) {
2067
+ if (e.rel !== rel || e.direction !== dir) continue;
2068
+ const label = e.other.type === "file" ? e.other.file : `${e.other.name} (${e.other.file})`;
2069
+ if (!seen.has(label)) {
2070
+ seen.add(label);
2071
+ out.push(label);
2072
+ }
2073
+ if (out.length >= 15) break;
2074
+ }
2075
+ if (out.length === 0 && fileHit != null && (rel === "CALLS" || rel === "USES")) {
2076
+ try {
2077
+ const rows = db.prepare(
2078
+ `SELECT DISTINCT t.name AS n, f.path AS p
2079
+ FROM relationships r
2080
+ JOIN symbols s ON s.id = ${dir === "out" ? "r.source_id" : "r.target_id"}
2081
+ JOIN symbols t ON t.id = ${dir === "out" ? "r.target_id" : "r.source_id"}
2082
+ JOIN files f ON f.id = t.file_id
2083
+ WHERE s.file_id = (SELECT file_id FROM symbols WHERE id = ?)
2084
+ AND r.relationship_type = ? AND t.type != 'file'
2085
+ LIMIT 8`
2086
+ ).all(center.id, rel);
2087
+ for (const r of rows) {
2088
+ const label = `${r.n} (${r.p})`;
2089
+ if (!seen.has(label)) {
2090
+ seen.add(label);
2091
+ out.push(label);
2092
+ }
2093
+ }
2094
+ } catch {
2095
+ }
2096
+ }
2097
+ return out;
2098
+ };
2099
+ const directFiles = [...new Set(direct.map((e) => e.other.file))].filter(
2100
+ (f) => f !== center.file
2101
+ );
2102
+ const indirectVals = [...indirect.values()];
2103
+ const testFiles = [
2104
+ .../* @__PURE__ */ new Set([
2105
+ ...direct.filter((e) => e.rel === "TESTS").map((e) => e.other.file),
2106
+ ...indirectVals.filter((v) => v.rel === "TESTS").map((v) => v.file),
2107
+ ...directFiles.filter((f) => /(test|spec)/i.test(f)),
2108
+ ...indirectVals.map((v) => v.file).filter((f) => /(test|spec)/i.test(f))
2109
+ ])
2110
+ ].slice(0, 10);
2111
+ const ui = [
2112
+ ...new Set(
2113
+ [...direct.map((e) => e.other), ...indirectVals].filter((n) => n.type === "component").map((n) => `${n.name} (${n.file})`)
2114
+ )
2115
+ ].slice(0, 10);
2116
+ const lines = [
2117
+ `# Impact: ${centerLabel}`,
2118
+ ``,
2119
+ `## Direct (${directFiles.length} file)`,
2120
+ ...directFiles.slice(0, 15).map((f) => `- ${fmtFile(f)}`),
2121
+ ...directFiles.length === 0 ? ["- (tidak ada)"] : [],
2122
+ ``,
2123
+ `## Detail`,
2124
+ `Diimpor oleh: ${byRel("IMPORTS", "in").join(", ") || "-"}`,
2125
+ `Memanggil: ${byRel("CALLS", "out").join(", ") || "-"}`,
2126
+ `Dipanggil oleh: ${byRel("CALLS", "in").join(", ") || "-"}`,
2127
+ ``,
2128
+ `## Indirect (${indirect.size})`,
2129
+ ...[...indirect.values()].slice(0, 15).map((v) => `- ${v.name} (${v.type}, ${v.file}) via ${v.via}`),
2130
+ ...indirect.size === 0 ? ["- (tidak ada)"] : [],
2131
+ ``,
2132
+ `## Tests`,
2133
+ ...testFiles.map((t) => `- ${t}`),
2134
+ ...testFiles.length === 0 ? ["- (tidak ada test terkait di index)"] : [],
2135
+ ``,
2136
+ `## UI Components`,
2137
+ ...ui.map((u) => `- ${u}`),
2138
+ ...ui.length === 0 ? ["- (tidak ada)"] : []
2139
+ ];
2140
+ console.log(lines.join("\n"));
2141
+ } finally {
2142
+ db.close();
2143
+ }
2144
+ }
2145
+
2146
+ // src/commands/init.ts
2147
+ import fs10 from "fs";
2148
+ import path11 from "path";
2149
+ function cmdInit(repoRoot2) {
2150
+ console.log("ScopeCairn");
2151
+ console.log("\u2713 Repository detected");
2152
+ const stats = scanRepository(repoRoot2);
2153
+ console.log(`\u2713 ${stats.sourceFiles} source files`);
2154
+ if (stats.metaFiles > 0) console.log(`\u2713 ${stats.metaFiles} config/schema files`);
2155
+ console.log(`\u2713 ${stats.symbols} symbols`);
2156
+ console.log(`\u2713 ${stats.relationships} relationships`);
2157
+ const prefix = detectPrefix(repoRoot2);
2158
+ const agentDir = detectAgentDir(repoRoot2);
2159
+ const agentsPath = path11.join(repoRoot2, "AGENTS.md");
2160
+ const block = agentsMd(prefix);
2161
+ if (fs10.existsSync(agentsPath)) {
2162
+ const cur = fs10.readFileSync(agentsPath, "utf8");
2163
+ if (cur.includes(MARKER)) {
2164
+ const re = new RegExp(`${MARKER}[\\s\\S]*?(?=\\n#[^#]|$)`, "");
2165
+ const next = cur.replace(re, block.trimEnd());
2166
+ fs10.writeFileSync(agentsPath, next.trimEnd() + "\n");
2167
+ console.log("\u2713 AGENTS.md updated (blok ScopeCairn diganti)");
2168
+ } else {
2169
+ fs10.writeFileSync(agentsPath, cur.trimEnd() + "\n\n" + block);
2170
+ console.log("\u2713 AGENTS.md extended (blok ScopeCairn ditambahkan)");
2171
+ }
2172
+ } else {
2173
+ fs10.writeFileSync(agentsPath, block);
2174
+ console.log("\u2713 AGENTS.md generated");
2175
+ }
2176
+ const skillPath = path11.join(repoRoot2, agentDir, "skills", "scopecairn", "SKILL.md");
2177
+ fs10.mkdirSync(path11.dirname(skillPath), { recursive: true });
2178
+ fs10.writeFileSync(skillPath, skillMd(prefix));
2179
+ console.log(`\u2713 Skill installed (${path11.relative(repoRoot2, skillPath)})`);
2180
+ const wfPath = path11.join(repoRoot2, "workflows", "scopecairn.md");
2181
+ fs10.mkdirSync(path11.dirname(wfPath), { recursive: true });
2182
+ fs10.writeFileSync(wfPath, workflowMd(prefix));
2183
+ console.log(`\u2713 Workflow installed (${path11.relative(repoRoot2, wfPath)})`);
2184
+ console.log("Ready.");
2185
+ console.log("");
2186
+ console.log(allowlistGuide(prefix));
2187
+ }
2188
+
2189
+ // src/benchmark/run.ts
2190
+ import fs11 from "fs";
2191
+ import path12 from "path";
2192
+
2193
+ // src/benchmark/dataset.ts
2194
+ var BENCHMARK_TASKS = [
2195
+ {
2196
+ task: "Add ranking weight configuration",
2197
+ expected: ["src/retrieval/retrieve.ts", "src/retrieval/config.ts"]
2198
+ },
2199
+ {
2200
+ task: "Change impact analysis for a file",
2201
+ expected: ["src/commands/impact.ts", "src/graph.ts"]
2202
+ },
2203
+ {
2204
+ task: "Task scope required optional protected files",
2205
+ expected: ["src/scope/scope.ts", "src/scope/protected.ts"]
2206
+ },
2207
+ {
2208
+ task: "Generate AGENTS.md skill workflow on init",
2209
+ expected: ["src/commands/init.ts", "src/integrate/templates.ts"]
2210
+ },
2211
+ {
2212
+ task: "Repository scanner ignore rules",
2213
+ expected: ["src/scanner.ts", "src/ignore.ts"]
2214
+ },
2215
+ {
2216
+ task: "Symbol extraction typescript functions classes",
2217
+ expected: ["src/extract/typescript.ts", "src/extract/types.ts"]
2218
+ },
2219
+ {
2220
+ task: "Invocation logging compliance rate",
2221
+ expected: ["src/invocations.ts", "src/commands/status.ts"]
2222
+ },
2223
+ {
2224
+ task: "Upgrade dependencies in package json",
2225
+ expected: ["package.json", "package-lock.json"]
2226
+ },
2227
+ {
2228
+ task: "Read symbol source excerpt",
2229
+ expected: ["src/commands/read.ts", "src/graph.ts"]
2230
+ },
2231
+ {
2232
+ task: "Git recency co-change signals ranking",
2233
+ expected: ["src/retrieval/git.ts", "src/retrieval/retrieve.ts"]
2234
+ },
2235
+ {
2236
+ task: "Complexity classifier simple versus complex",
2237
+ expected: ["src/retrieval/classify.ts", "src/retrieval/contextBuilder.ts"]
2238
+ },
2239
+ {
2240
+ task: "Health check integration files prefix",
2241
+ expected: ["src/commands/doctor.ts", "src/integrate/detect.ts"]
2242
+ }
2243
+ ];
2244
+
2245
+ // src/benchmark/run.ts
2246
+ function fileChars(repoRoot2, rel) {
2247
+ try {
2248
+ return fs11.readFileSync(path12.join(repoRoot2, rel), "utf8").length;
2249
+ } catch {
2250
+ return 0;
2251
+ }
2252
+ }
2253
+ function runBenchmark(db, repoRoot2, opts) {
2254
+ const tasks = [];
2255
+ for (const t of BENCHMARK_TASKS) {
2256
+ const res = retrieve(db, repoRoot2, t.task, opts);
2257
+ const files = [];
2258
+ const high = [];
2259
+ for (const r of res.ranked) {
2260
+ if (r.score >= 0.3 && !high.includes(r.file)) high.push(r.file);
2261
+ if (r.score < 0.15) break;
2262
+ if (!files.includes(r.file)) files.push(r.file);
2263
+ if (files.length >= 10) break;
2264
+ }
2265
+ const firstRead = high.length > 0 ? high : files.slice(0, 3);
2266
+ const hits = t.expected.filter((e) => files.includes(e));
2267
+ const recall = t.expected.length > 0 ? hits.length / t.expected.length : 1;
2268
+ const highHits = t.expected.filter((e) => firstRead.includes(e));
2269
+ const irrelevantRatio = firstRead.length > 0 ? (firstRead.length - highHits.length) / firstRead.length : 0;
2270
+ const baselineChars = t.expected.reduce((a, f) => a + fileChars(repoRoot2, f), 0) * 2;
2271
+ const optimizedChars = Math.max(1, res.ranked.slice(0, 8).length * 1200);
2272
+ const contextReduction = baselineChars > 0 ? Math.max(0, 1 - optimizedChars / baselineChars) : 0;
2273
+ tasks.push({
2274
+ task: t.task,
2275
+ expected: t.expected,
2276
+ retrievedFiles: files,
2277
+ highFiles: firstRead,
2278
+ hits,
2279
+ recall,
2280
+ irrelevantRatio,
2281
+ baselineChars,
2282
+ optimizedChars,
2283
+ contextReduction
2284
+ });
2285
+ }
2286
+ const avg = (xs) => xs.length > 0 ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
2287
+ return {
2288
+ tasks,
2289
+ avgRecall: avg(tasks.map((t) => t.recall)),
2290
+ avgIrrelevant: avg(tasks.map((t) => t.irrelevantRatio)),
2291
+ avgReduction: avg(tasks.map((t) => t.contextReduction)),
2292
+ recallAtThreshold: tasks.filter((t) => t.recall >= 0.5).length / tasks.length
2293
+ };
2294
+ }
2295
+ function tuneWeights(db, repoRoot2) {
2296
+ const base = {
2297
+ weights: { wSeed: 0.3, wProximity: 0.35, wCentrality: 0.15 },
2298
+ avgRecall: 0,
2299
+ avgIrrelevant: 0
2300
+ };
2301
+ const baseSum = runBenchmark(db, repoRoot2, { weights: base.weights });
2302
+ base.avgRecall = baseSum.avgRecall;
2303
+ base.avgIrrelevant = baseSum.avgIrrelevant;
2304
+ let best = base;
2305
+ let tried = 0;
2306
+ for (const wSeed of [0.2, 0.3, 0.4, 0.5]) {
2307
+ for (const wProximity of [0.25, 0.35, 0.45]) {
2308
+ for (const wCentrality of [0.1, 0.15, 0.2]) {
2309
+ tried++;
2310
+ const s = runBenchmark(db, repoRoot2, {
2311
+ weights: { wSeed, wProximity, wCentrality }
2312
+ });
2313
+ const better = s.avgRecall > best.avgRecall || s.avgRecall === best.avgRecall && s.avgIrrelevant < best.avgIrrelevant;
2314
+ if (better) {
2315
+ best = {
2316
+ weights: { wSeed, wProximity, wCentrality },
2317
+ avgRecall: s.avgRecall,
2318
+ avgIrrelevant: s.avgIrrelevant
2319
+ };
2320
+ }
2321
+ }
2322
+ }
2323
+ }
2324
+ return { best, baseline: base, tried };
2325
+ }
2326
+
2327
+ // src/benchmark/report.ts
2328
+ function benchmarkReport(summary, tune, gitAvailable) {
2329
+ const pct = (x) => `${(x * 100).toFixed(0)}%`;
2330
+ const lines = [
2331
+ `# ScopeCairn Benchmark`,
2332
+ ``,
2333
+ `Dataset: ${summary.tasks.length} task (repo sendiri, ground truth file relevan).`,
2334
+ `Git signals: ${gitAvailable ? "aktif" : "nonaktif (bukan repo git \u2014 wRecency/wCochange = 0)"}.`,
2335
+ ``,
2336
+ `## Ringkasan otomatis`,
2337
+ ``,
2338
+ `| Metrik | Hasil | Ambang \xA718 | Status |`,
2339
+ `|---|---|---|---|`,
2340
+ `| Retrieval recall (rata-rata, konteks penuh) | ${pct(summary.avgRecall)} | ditetapkan awal \u2265 70% | ${summary.avgRecall >= 0.7 ? "\u2713" : "\u2717"} |`,
2341
+ `| Task recall \u2265 50% | ${pct(summary.recallAtThreshold)} | \u2014 | \u2014 |`,
2342
+ `| Irrelevant file ratio (bacaan pertama HIGH) | ${pct(summary.avgIrrelevant)} | < 20% | ${summary.avgIrrelevant < 0.2 ? "\u2713" : "\u2717"} |`,
2343
+ `| Context reduction (estimasi) | ${pct(summary.avgReduction)} | \u2265 30% | ${summary.avgReduction >= 0.3 ? "\u2713" : "\u2717"} |`,
2344
+ ``,
2345
+ `## Per task`,
2346
+ ``,
2347
+ `| Task | Recall | Irrelevant | Ditemukan | Bacaan pertama |`,
2348
+ `|---|---|---|---|---|`
2349
+ ];
2350
+ for (const t of summary.tasks) {
2351
+ lines.push(
2352
+ `| ${t.task} | ${pct(t.recall)} | ${pct(t.irrelevantRatio)} | ${t.hits.join(", ") || "-"} | ${t.highFiles.join(", ") || "-"} |`
2353
+ );
2354
+ }
2355
+ if (tune) {
2356
+ const b = tune.best;
2357
+ lines.push(
2358
+ ``,
2359
+ `## Kalibrasi bobot (${tune.tried} kombinasi)`,
2360
+ ``,
2361
+ `Baseline: seed=${tune.baseline.weights.wSeed} prox=${tune.baseline.weights.wProximity} cent=${tune.baseline.weights.wCentrality} \u2192 recall ${pct(tune.baseline.avgRecall)}, irrelevant ${pct(tune.baseline.avgIrrelevant)}`,
2362
+ `Terbaik: seed=${b.weights.wSeed} prox=${b.weights.wProximity} cent=${b.weights.wCentrality} \u2192 recall ${pct(b.avgRecall)}, irrelevant ${pct(b.avgIrrelevant)}`,
2363
+ tune.best.avgRecall - tune.baseline.avgRecall > 0.05 ? `Rekomendasi: terapkan ke \`.scopecairn/config.yml\` (gain > 5%).` : `Rekomendasi: pertahankan default (gain \u2264 5% \u2014 hindari overfit ke 12 task).`
2364
+ );
2365
+ }
2366
+ lines.push(
2367
+ ``,
2368
+ `## Protokol manual (butuh agent run)`,
2369
+ ``,
2370
+ `Otomatisasi di atas tak mengukur: token input riil, tool call, waktu,`,
2371
+ `task success, compliance rate. Protokol: 10 task nyata \xD7 2 kondisi`,
2372
+ `(agent saja vs agent + \`scopecairn context\`), catat token/tool/file/success`,
2373
+ `per \xA718.2. Compliance = task dengan pemanggilan tercatat di \`invocations\`.`
2374
+ );
2375
+ return lines.join("\n");
2376
+ }
2377
+
2378
+ // src/commands/benchmark.ts
2379
+ function cmdBenchmark(repoRoot2, opts = {}) {
2380
+ if (!opts.noRefresh) refreshIndex(repoRoot2);
2381
+ const db = openDb(repoRoot2);
2382
+ try {
2383
+ const fileCount = db.prepare(`SELECT COUNT(*) AS n FROM files`).get().n;
2384
+ if (fileCount === 0) {
2385
+ console.log("Index kosong. Jalankan `scopecairn scan` dulu.");
2386
+ return;
2387
+ }
2388
+ const git = gitSignals(repoRoot2, []);
2389
+ const summary = runBenchmark(db, repoRoot2);
2390
+ const tune = opts.tune ? tuneWeights(db, repoRoot2) : null;
2391
+ console.log(benchmarkReport(summary, tune, git.available));
2392
+ } finally {
2393
+ db.close();
2394
+ }
2395
+ }
2396
+
2397
+ // src/cli.ts
2398
+ var program = new Command();
2399
+ program.name("scopecairn").description("ScopeCairn \u2014 local-first codebase intelligence layer").version("0.1.0");
2400
+ function repoRoot() {
2401
+ return path13.resolve(process.cwd());
2402
+ }
2403
+ program.command("scan").description("Index repository (Fase 1: files + hash + language)").action(() => cmdScan(repoRoot()));
2404
+ program.command("status").description("Show index status").action(() => cmdStatus(repoRoot()));
2405
+ program.command("clean").description("Remove generated index data").action(() => cmdClean(repoRoot()));
2406
+ program.command("rebuild").description("Rebuild index from scratch (recover from corrupt index)").action(() => cmdRebuild(repoRoot()));
2407
+ program.command("doctor").description("Check integration health").action(async () => {
2408
+ await cmdDoctor(repoRoot());
2409
+ });
2410
+ program.command("graph <symbol>").description("Show relations of a symbol (Fase 2: 1-hop traversal)").action((symbol) => cmdGraph(repoRoot(), symbol));
2411
+ program.command("query <text>").description("Search symbols/codebase knowledge (substring match)").action((text) => cmdQuery(repoRoot(), text));
2412
+ program.command("read").argument("<kind>", "what to read (symbol)").argument("<name>", "symbol name").description("Read a symbol's source excerpt (not the whole file)").action((kind, name) => cmdRead(repoRoot(), kind, name));
2413
+ program.command("context <task>").description("Main entry point: auto-refresh + classify + relevant context + scope").option("--escalate", "force full COMPLEX context").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
2414
+ (task, opts) => cmdContext(repoRoot(), task, opts)
2415
+ );
2416
+ program.command("impact <target>").description("Change impact analysis: file, symbol, function, or class").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
2417
+ (target, opts) => cmdImpact(repoRoot(), target, opts)
2418
+ );
2419
+ program.command("init").description("Initial indexing + generate AGENTS.md, Skill, Workflow").action(() => cmdInit(repoRoot()));
2420
+ program.command("benchmark").description("Retrieval recall, irrelevant ratio, context reduction + weight tuning").option("--tune", "grid search bobot FR-05").option("--no-refresh", "skip incremental refresh (debugging/benchmark)").action(
2421
+ (opts) => cmdBenchmark(repoRoot(), opts)
2422
+ );
2423
+ program.parseAsync(process.argv);