@raymondchins/agentmap 0.5.0 → 0.6.1
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/agentmap.mjs +171 -22
- package/package.json +1 -1
package/agentmap.mjs
CHANGED
|
@@ -17,6 +17,9 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdir
|
|
|
17
17
|
import { execSync, execFileSync } from "node:child_process";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
20
23
|
|
|
21
24
|
// Lazy ts-morph: its ~105ms module init only fires on a COLD rebuild. Warm cache
|
|
22
25
|
// queries (the common case) never construct a Project, so they skip the load
|
|
@@ -61,6 +64,10 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
|
|
|
61
64
|
// Live content search for the --any fallback. `git grep` over tracked +
|
|
62
65
|
// untracked files (skips gitignored paths like node_modules). Reads DISK, so
|
|
63
66
|
// never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
|
|
67
|
+
// -i = case-insensitive BY DESIGN (discovery ergonomics, matches --find which
|
|
68
|
+
// lowercases its query): a "content" hit may differ in case from the query as
|
|
69
|
+
// typed, but every match is printed verbatim with file:line so the true casing
|
|
70
|
+
// is always visible — results are a superset, never a falsified exact-case hit.
|
|
64
71
|
// stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
|
|
65
72
|
// Exclude sensitive files from the --untracked sweep so a local .env / key /
|
|
66
73
|
// secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
|
|
@@ -85,8 +92,11 @@ const dirtyCount = () =>
|
|
|
85
92
|
// listed individually (default "all" folds it to "?? newdir/" and the
|
|
86
93
|
// extension regex misses it → a STALE cache would be served).
|
|
87
94
|
sh("git status --porcelain --untracked-files=all").split("\n").filter(Boolean).filter((l) => {
|
|
95
|
+
const xy = l.slice(0, 2); // porcelain status code (XY)
|
|
88
96
|
let p = l.slice(3); // strip "XY " status prefix
|
|
89
|
-
|
|
97
|
+
// only rename/copy entries use the ` old -> new ` form — gating on the status
|
|
98
|
+
// code avoids falsely splitting a plain file whose NAME contains " -> ".
|
|
99
|
+
if (/[RC]/.test(xy) && p.includes(" -> ")) p = p.split(" -> ").pop(); // rename/copy: keep new path
|
|
90
100
|
p = p.replace(/^"|"$/g, ""); // unquote space/special paths
|
|
91
101
|
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
|
|
92
102
|
}).length;
|
|
@@ -104,8 +114,13 @@ const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
|
|
|
104
114
|
function sourceFingerprint() {
|
|
105
115
|
try {
|
|
106
116
|
const entries = [];
|
|
107
|
-
const walk = (dir) => {
|
|
108
|
-
|
|
117
|
+
const walk = (dir, depth) => {
|
|
118
|
+
if (depth > 40) return; // depth cap — don't fully walk a pathologically deep tree
|
|
119
|
+
// per-directory try/catch: a single permission-denied subdir must NOT abort
|
|
120
|
+
// the WHOLE walk (that would return "" and silently disable caching) — skip
|
|
121
|
+
// the unreadable dir and keep going so the fingerprint stays usable.
|
|
122
|
+
let names; try { names = readdirSync(dir); } catch { return; }
|
|
123
|
+
for (const name of names) {
|
|
109
124
|
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
110
125
|
const full = dir + "/" + name;
|
|
111
126
|
let st;
|
|
@@ -115,11 +130,11 @@ function sourceFingerprint() {
|
|
|
115
130
|
// recursion / stack overflow.
|
|
116
131
|
try { st = lstatSync(full); } catch { continue; }
|
|
117
132
|
if (st.isSymbolicLink()) continue;
|
|
118
|
-
if (st.isDirectory()) walk(full);
|
|
133
|
+
if (st.isDirectory()) walk(full, depth + 1);
|
|
119
134
|
else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
|
|
120
135
|
}
|
|
121
136
|
};
|
|
122
|
-
walk(".");
|
|
137
|
+
walk(".", 0);
|
|
123
138
|
entries.sort();
|
|
124
139
|
return createHash("sha1").update(entries.join("\n")).digest("hex");
|
|
125
140
|
} catch { return ""; }
|
|
@@ -344,19 +359,21 @@ function makeProject() {
|
|
|
344
359
|
]);
|
|
345
360
|
// Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
|
|
346
361
|
try {
|
|
347
|
-
const walk = (dir) => {
|
|
348
|
-
|
|
362
|
+
const walk = (dir, depth) => {
|
|
363
|
+
if (depth > 40) return; // depth cap, matching sourceFingerprint()
|
|
364
|
+
let names; try { names = readdirSync(dir); } catch { return; } // skip unreadable dir, don't abort the whole walk
|
|
365
|
+
for (const name of names) {
|
|
349
366
|
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
350
367
|
const full = dir + "/" + name;
|
|
351
368
|
// lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
|
|
352
369
|
// a circular symlink would otherwise recurse until the stack overflows.
|
|
353
370
|
let st; try { st = lstatSync(full); } catch { continue; }
|
|
354
371
|
if (st.isSymbolicLink()) continue;
|
|
355
|
-
if (st.isDirectory()) walk(full);
|
|
372
|
+
if (st.isDirectory()) walk(full, depth + 1);
|
|
356
373
|
else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
|
|
357
374
|
}
|
|
358
375
|
};
|
|
359
|
-
walk(".");
|
|
376
|
+
walk(".", 0);
|
|
360
377
|
} catch { /* ignore — proceed without Vue */ }
|
|
361
378
|
}
|
|
362
379
|
// Build the virtual→real map and register each `<script>` block as a virtual
|
|
@@ -373,7 +390,7 @@ function makeProject() {
|
|
|
373
390
|
vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
|
|
374
391
|
vueReal[`${cwdp}/${f}`] = true;
|
|
375
392
|
}
|
|
376
|
-
return { project, vueMap, vueReal };
|
|
393
|
+
return { project, vueMap, vueReal, aliasOpts };
|
|
377
394
|
}
|
|
378
395
|
|
|
379
396
|
// ---------------------------------------------------------------------------
|
|
@@ -383,7 +400,7 @@ function makeProject() {
|
|
|
383
400
|
// ---------------------------------------------------------------------------
|
|
384
401
|
function build() {
|
|
385
402
|
const t0 = Date.now();
|
|
386
|
-
const { project, vueMap, vueReal } = makeProject();
|
|
403
|
+
const { project, vueMap, vueReal, aliasOpts } = makeProject();
|
|
387
404
|
const { SyntaxKind } = tsMorph();
|
|
388
405
|
const CallExpression = SyntaxKind.CallExpression;
|
|
389
406
|
const cwd = process.cwd().replace(/\\/g, "/");
|
|
@@ -405,8 +422,51 @@ function build() {
|
|
|
405
422
|
// /index.*. Returns the rel key or null. Powers side-effect (6b) + dynamic
|
|
406
423
|
// import()/require() (6c) edges that ts-morph's specifier resolution skips.
|
|
407
424
|
const RES_EXT = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
|
|
425
|
+
// posix-join that collapses "" / "." / ".." segments (no fs access).
|
|
426
|
+
const joinPosix = (a, b) => {
|
|
427
|
+
const parts = (a + "/" + b).split("/"); const st = [];
|
|
428
|
+
for (const seg of parts) { if (seg === "" || seg === ".") continue; if (seg === "..") st.pop(); else st.push(seg); }
|
|
429
|
+
return (a.startsWith("/") ? "/" : "") + st.join("/");
|
|
430
|
+
};
|
|
431
|
+
// resolve an absolute-ish path to an in-project source file, honoring
|
|
432
|
+
// extensionless + /index.* + .vue resolution (shared by relative + alias paths).
|
|
433
|
+
const tryResolveAt = (abs) => {
|
|
434
|
+
if (vueReal[abs]) return rel(abs);
|
|
435
|
+
let sf = project.getSourceFile(abs);
|
|
436
|
+
if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}.${e}`); if (sf) break; }
|
|
437
|
+
if (!sf) for (const e of RES_EXT) { sf = project.getSourceFile(`${abs}/index.${e}`); if (sf) break; }
|
|
438
|
+
if (sf) return rel(sf.getFilePath());
|
|
439
|
+
if (vueReal[`${abs}.vue`]) return rel(`${abs}.vue`);
|
|
440
|
+
return null;
|
|
441
|
+
};
|
|
442
|
+
// #3 fix: tsconfig/jsconfig baseUrl+paths alias resolution ("@/x", "~/x") for
|
|
443
|
+
// the side-effect/dynamic/require edges too. Static imports already resolve via
|
|
444
|
+
// ts-morph's getModuleSpecifierSourceFile(); without this, `import "@/lib/x"`,
|
|
445
|
+
// `import("@/lib/x")` and `require("@/lib/x")` silently formed NO edge.
|
|
446
|
+
const ROOTABS = process.cwd().replace(/\\/g, "/");
|
|
447
|
+
const aliasBase = aliasOpts.baseUrl ? joinPosix(ROOTABS, aliasOpts.baseUrl) : ROOTABS;
|
|
448
|
+
const aliasEntries = Object.entries(aliasOpts.paths || {});
|
|
449
|
+
const resolveAlias = (spec) => {
|
|
450
|
+
for (const [pat, targets] of aliasEntries) {
|
|
451
|
+
const star = pat.indexOf("*");
|
|
452
|
+
let sub = null;
|
|
453
|
+
if (star === -1) { if (spec === pat) sub = ""; else continue; }
|
|
454
|
+
else {
|
|
455
|
+
const pre = pat.slice(0, star), suf = pat.slice(star + 1);
|
|
456
|
+
if (spec.length < pre.length + suf.length || !spec.startsWith(pre) || !spec.endsWith(suf)) continue;
|
|
457
|
+
sub = spec.slice(pre.length, spec.length - suf.length);
|
|
458
|
+
}
|
|
459
|
+
for (const tRaw of (Array.isArray(targets) ? targets : [targets])) {
|
|
460
|
+
const tStar = tRaw.indexOf("*");
|
|
461
|
+
const candidate = tStar === -1 ? tRaw : tRaw.slice(0, tStar) + sub + tRaw.slice(tStar + 1);
|
|
462
|
+
const hit = tryResolveAt(joinPosix(aliasBase, candidate));
|
|
463
|
+
if (hit) return hit;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
};
|
|
408
468
|
const resolveSpec = (fromAbsDir, spec) => {
|
|
409
|
-
if (!spec.startsWith(".")) return
|
|
469
|
+
if (!spec.startsWith(".")) return resolveAlias(spec); // alias (baseUrl/paths) or null for non-relative
|
|
410
470
|
// normalize fromAbsDir + spec into an absolute-ish posix path
|
|
411
471
|
const join = (a, b) => {
|
|
412
472
|
const parts = (a + "/" + b).split("/"); const st = [];
|
|
@@ -435,7 +495,9 @@ function build() {
|
|
|
435
495
|
for (const sf of sourceFiles) {
|
|
436
496
|
const path = rel(sf.getFilePath());
|
|
437
497
|
if (excluded(path)) continue;
|
|
498
|
+
try {
|
|
438
499
|
const fromDir = sf.getDirectoryPath().replace(/\\/g, "/");
|
|
500
|
+
const reExports = new Set(); // #2: names that are pass-through re-exports, not real uses
|
|
439
501
|
// exports, remembering which exported name was the file's DEFAULT export so
|
|
440
502
|
// default-import edges can later resolve "default" → the real symbol name.
|
|
441
503
|
let defaultExportName = null;
|
|
@@ -472,7 +534,11 @@ function build() {
|
|
|
472
534
|
for (const exp of sf.getExportDeclarations()) {
|
|
473
535
|
if (exp.isTypeOnly()) continue; // type-only re-exports excluded from edges
|
|
474
536
|
const t = exp.getModuleSpecifierSourceFile();
|
|
475
|
-
if (t)
|
|
537
|
+
if (t) {
|
|
538
|
+
const names = exp.getNamedExports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
|
|
539
|
+
addEdge(rel(t.getFilePath()), names); // keep the FILE-level edge (barrel depends on origin)
|
|
540
|
+
for (const n of names) reExports.add(n); // #2: mark as re-export so rankSymbols won't count it as a reference
|
|
541
|
+
}
|
|
476
542
|
}
|
|
477
543
|
// 6c: dynamic import("./x") and require("./x") with relative, in-project
|
|
478
544
|
// string-literal specifiers → edge with names ["*"]. Prefilter on raw text
|
|
@@ -491,9 +557,15 @@ function build() {
|
|
|
491
557
|
}
|
|
492
558
|
const imports = Object.keys(importedSymbols);
|
|
493
559
|
for (const tp of imports) (dependents[tp] ??= []).push(path);
|
|
494
|
-
files[path] = { exports, imports, importedSymbols, defaultExportName };
|
|
560
|
+
files[path] = { exports, imports, importedSymbols, defaultExportName, reExports: [...reExports] };
|
|
495
561
|
const feat = featureOf(path);
|
|
496
562
|
if (feat) (features[feat] ??= []).push(path);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
// #1 fix: a single pathological file (malformed import specifier, ts-morph
|
|
565
|
+
// edge case) must NOT abort the whole map — skip it + warn, preserving the
|
|
566
|
+
// graceful-degradation contract agentmap advertises.
|
|
567
|
+
process.stderr.write(`# agentmap: skipped ${path} (parse error: ${e?.message ?? e})\n`);
|
|
568
|
+
}
|
|
497
569
|
}
|
|
498
570
|
// 7: resolve default-import edges. A default import was recorded literally as
|
|
499
571
|
// "default"; rankSymbols skips "default", so default-exported symbols (the
|
|
@@ -561,10 +633,12 @@ function rankSymbols(files, focus) {
|
|
|
561
633
|
definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
|
|
562
634
|
}
|
|
563
635
|
}
|
|
564
|
-
for (const [p, f] of Object.entries(files))
|
|
636
|
+
for (const [p, f] of Object.entries(files)) {
|
|
637
|
+
const reExp = new Set(f.reExports || []); // #2: pass-through re-exports aren't real references
|
|
565
638
|
for (const tp of f.imports)
|
|
566
639
|
for (const name of f.importedSymbols[tp] || [])
|
|
567
|
-
if (name !== "*" && name !== "default") getOrSet(references, name, () => []).push(p);
|
|
640
|
+
if (name !== "*" && name !== "default" && !reExp.has(name)) getOrSet(references, name, () => []).push(p);
|
|
641
|
+
}
|
|
568
642
|
|
|
569
643
|
// mentioned idents from focus files' exports + their basenames
|
|
570
644
|
let mentioned = null;
|
|
@@ -724,7 +798,7 @@ function parseSettings(text, settingsPath) {
|
|
|
724
798
|
try { return JSON.parse(text) || {}; }
|
|
725
799
|
catch {
|
|
726
800
|
try { return JSON.parse(stripJsonComments(text)) || {}; }
|
|
727
|
-
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run
|
|
801
|
+
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run`); }
|
|
728
802
|
}
|
|
729
803
|
}
|
|
730
804
|
|
|
@@ -843,6 +917,65 @@ function installHooks({ dryRun = false } = {}) {
|
|
|
843
917
|
console.log("\nDone — the map auto-refreshes on commit, and greps are nudged to agentmap first.");
|
|
844
918
|
}
|
|
845
919
|
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// --setup-mcp: register the agentmap MCP server in the global configs of
|
|
922
|
+
// MCP-capable IDEs that aren't Claude Code (which uses --install-hooks instead).
|
|
923
|
+
// Merge-safe + idempotent; with { dryRun:true } it prints the plan and writes
|
|
924
|
+
// nothing. Throws on the first malformed config so the caller can stderr+exit 1.
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
function setupMcp({ dryRun = false } = {}) {
|
|
927
|
+
const mcpPath = fileURLToPath(new URL("./mcp.mjs", import.meta.url));
|
|
928
|
+
|
|
929
|
+
// npx materializes the package under a `_npx` cache dir that gets garbage-
|
|
930
|
+
// collected, so a config pointing at that path would rot. When invoked via npx,
|
|
931
|
+
// pin to the published spec instead; otherwise reference the resolved file.
|
|
932
|
+
const isNpx = mcpPath.includes("_npx");
|
|
933
|
+
const command = isNpx ? "npx" : process.execPath;
|
|
934
|
+
const args = isNpx ? ["-y", "@raymondchins/agentmap", "--mcp"] : [mcpPath];
|
|
935
|
+
|
|
936
|
+
// Each target: a global config file + how to graft the agentmap entry into it.
|
|
937
|
+
// Antigravity is written to BOTH paths on purpose — older builds read only the
|
|
938
|
+
// IDE-specific ~/.gemini/antigravity path, newer unified builds read the shared
|
|
939
|
+
// ~/.gemini/config path, so writing both is version-proof.
|
|
940
|
+
const targets = [
|
|
941
|
+
{
|
|
942
|
+
label: "OpenCode",
|
|
943
|
+
path: join(homedir(), ".config", "opencode", "opencode.json"),
|
|
944
|
+
graft: (cfg) => { (cfg.mcp ??= {}).agentmap = { type: "stdio", command, args, enabled: true }; },
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
label: "Antigravity IDE",
|
|
948
|
+
path: join(homedir(), ".gemini", "antigravity", "mcp_config.json"),
|
|
949
|
+
graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
label: "Antigravity (shared)",
|
|
953
|
+
path: join(homedir(), ".gemini", "config", "mcp_config.json"),
|
|
954
|
+
graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
|
|
955
|
+
},
|
|
956
|
+
];
|
|
957
|
+
|
|
958
|
+
if (dryRun) console.log("--dry-run: would configure MCP server (no changes written):");
|
|
959
|
+
|
|
960
|
+
for (const { label, path, graft } of targets) {
|
|
961
|
+
// Reuse parseSettings so JSONC (comments) is tolerated and a malformed file
|
|
962
|
+
// throws a clear error WITHOUT clobbering the original (we never write on the
|
|
963
|
+
// failure path, so no .bak dance is needed).
|
|
964
|
+
let cfg = {};
|
|
965
|
+
if (existsSync(path)) cfg = parseSettings(readFileSync(path, "utf8"), path);
|
|
966
|
+
graft(cfg);
|
|
967
|
+
|
|
968
|
+
if (dryRun) {
|
|
969
|
+
console.log(` ${label}: would write to ${path}`);
|
|
970
|
+
} else {
|
|
971
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
972
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
|
973
|
+
console.log(`configured ${label} MCP server → ${path}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
|
|
846
979
|
// ---------------------------------------------------------------------------
|
|
847
980
|
// CLI
|
|
848
981
|
// ---------------------------------------------------------------------------
|
|
@@ -865,7 +998,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
|
|
|
865
998
|
// NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
|
|
866
999
|
const KNOWN = new Set([
|
|
867
1000
|
"--json", "--print",
|
|
868
|
-
"--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
|
|
1001
|
+
"--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--setup-mcp", "--mcp",
|
|
869
1002
|
"--any", "--find", "--relates", "--map", "--focus", "--tokens",
|
|
870
1003
|
"--symbols", "--feature", "--features", "--hubs",
|
|
871
1004
|
]);
|
|
@@ -902,6 +1035,9 @@ Maintenance:
|
|
|
902
1035
|
--install-hooks [--dry-run]
|
|
903
1036
|
install git post-commit + copy the PreToolUse nudge +
|
|
904
1037
|
wire .claude/settings.json (--dry-run = preview, no writes)
|
|
1038
|
+
--setup-mcp [--dry-run]
|
|
1039
|
+
configure MCP server for OpenCode & Antigravity IDE
|
|
1040
|
+
(--dry-run = preview, no writes)
|
|
905
1041
|
--mcp start a stdio MCP server (for MCP-capable agents)
|
|
906
1042
|
--help, -h show this help
|
|
907
1043
|
--version, -v print the version
|
|
@@ -937,6 +1073,11 @@ else if (has("--install-hooks")) {
|
|
|
937
1073
|
try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
|
|
938
1074
|
catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
|
|
939
1075
|
}
|
|
1076
|
+
// --setup-mcp: configure MCP server for OpenCode & Antigravity IDE.
|
|
1077
|
+
else if (has("--setup-mcp")) {
|
|
1078
|
+
try { setupMcp({ dryRun: has("--dry-run") }); process.exit(0); }
|
|
1079
|
+
catch (e) { console.error(`agentmap --setup-mcp failed: ${e?.message || e}`); process.exit(1); }
|
|
1080
|
+
}
|
|
940
1081
|
// Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
|
|
941
1082
|
// 2). Runs BEFORE the bare-build fallthrough so a typo never silently rebuilds.
|
|
942
1083
|
// Bare invocation with NO flags still builds (handled in the final else).
|
|
@@ -1087,11 +1228,19 @@ else if (has("--any")) {
|
|
|
1087
1228
|
// omitted. Otherwise `continue` so smaller lower-ranked files can still
|
|
1088
1229
|
// fill the remaining budget (don't `break` on the first overflow).
|
|
1089
1230
|
if (first && budget > 0) {
|
|
1090
|
-
|
|
1091
|
-
while (partial.length > 1)
|
|
1092
|
-
|
|
1231
|
+
// #6 fix: try progressively fewer symbols DOWN TO ONE. The old
|
|
1232
|
+
// `while (partial.length > 1)` sliced before testing and never tried
|
|
1233
|
+
// the single-symbol block, so a tiny --tokens could emit NOTHING for
|
|
1234
|
+
// the top file despite the "never wholly omitted" intent. If even one
|
|
1235
|
+
// symbol overflows the budget, nothing fits (correct) — but we now try.
|
|
1236
|
+
for (let k = capped.length - 1; k >= 1; k--) {
|
|
1237
|
+
const partial = capped.slice(0, k);
|
|
1093
1238
|
const pt = tokEst(lineOf(partial));
|
|
1094
|
-
if (used + pt <= budget) {
|
|
1239
|
+
if (used + pt <= budget) {
|
|
1240
|
+
used += pt;
|
|
1241
|
+
shownFiles.push({ file, symbols: partial.map((s) => ({ name: s.name, kind: s.kind })) });
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1095
1244
|
}
|
|
1096
1245
|
first = false;
|
|
1097
1246
|
}
|