bantamkit-mcp 0.29.2 → 0.30.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.
@@ -0,0 +1,1040 @@
1
+ /**
2
+ * Repo map: hand-rolled definition scan, personalised PageRank, byte-budgeted listing.
3
+ * The Node half of `runtime-py/src/bantamkit/repomap.py`.
4
+ *
5
+ * Roadmap #10. **This is a precision feature and it is not a token saving.** Row 10's own
6
+ * gate — "build only after #4 shows discovery tokens dominate" — was REFUTED by
7
+ * measurement: discovery is 4,266.6 k estimated tokens, 33.8 % of tool-result bytes, but
8
+ * **0.114 %** of 3,739,207.9 k real prompt tokens, because 97.8 % of the real bill is
9
+ * `cache_read`. It ships on an explicit user ruling to build it anyway. Nothing here saves
10
+ * bytes; what it buys is the right file found sooner.
11
+ *
12
+ * **No parser, and that is a ruling rather than a shortcut.** Row 10 says "tree-sitter
13
+ * defs". This package declares exactly one runtime dependency
14
+ * (`@modelcontextprotocol/sdk`) and the standing ruling is a pure-node `npx` install, so
15
+ * tree-sitter cannot land here. Landing it in Python ALONE would be strictly worse than
16
+ * landing none: the two runtimes would extract different definitions from the same file
17
+ * and every conformance case over this module would become a ruled divergence. So both
18
+ * sides hand-roll the same scanner, per the `pyyaml.ts` / `pyjson.ts` / `pyargparse.ts`
19
+ * idiom.
20
+ *
21
+ * ---------------------------------------------------------------------------------------
22
+ * THE PORT CONTRACT — every constant below is PINNED and none may be re-derived here
23
+ * ---------------------------------------------------------------------------------------
24
+ * `repomap.py`'s docstring holds the measurement that chose each number, on this
25
+ * repository's own 235 tracked source files against a yardstick that is NOT an input to
26
+ * the ranking (where do the focus file's own in-repo imports land in the ranking?).
27
+ * `.shiftwork/notes-job45/J45-9-repomap.md` holds the commands. Reproduced here as the
28
+ * SHORT form, so that a reader of this file knows a change is a measured regression and
29
+ * not a tidy-up:
30
+ *
31
+ * - `DAMPING = 0.20` — 0.85 is calibrated for global web authority and this is a LOCAL
32
+ * query; recall@10 0.614 at 0.20 against 0.313 at 0.85.
33
+ * - `ITERATIONS = 30`, no early stop — the vector reaches an EXACT fixed point at round
34
+ * 16 on this repository and the remaining 14 rounds change no bit. A fixed count is one
35
+ * fewer float comparison for the two runtimes to disagree about.
36
+ * - `REFERENCE_DF_MAX_NUM / _DEN = 1/8` — the one knob whose curve TURNS: none / 1/2 /
37
+ * 1/4 / 1/8 / 1/16 give recall@10 0.614 / 0.614 / 0.687 / **0.735** / 0.627.
38
+ * - `MIN_REFERENCE_LENGTH = 3` — one- and two-character names are loop variables; 4 and 5
39
+ * score better on the yardstick by dropping real API names, which is overfitting.
40
+ * - a name must be defined in EXACTLY ONE file to carry an edge (the rule lives in
41
+ * `nameIndex`'s ambiguity set, deliberately NOT a constant — see there).
42
+ * - `MAX_DEFINITIONS_PER_FILE = 4`, `DEFAULT_BUDGET = 4000` UTF-8 BYTES.
43
+ *
44
+ * Edges are NAME REFERENCES, never imports: import-only edges leave 232 of 235 files at
45
+ * score exactly 0.0 and have not converged after 40 rounds.
46
+ *
47
+ * ---------------------------------------------------------------------------------------
48
+ * THE FOUR TRAPS, RE-PROBED IN NODE BY J45-10 RATHER THAN INHERITED
49
+ * ---------------------------------------------------------------------------------------
50
+ * J45-2 and J45-3 pinned six CPython/JS differences in `dream`; `repomap.py` numbers four
51
+ * more on from those, and this port re-ran each one on node v25.2.1 against CPython 3.12.13
52
+ * (`.shiftwork/notes-job45/J45-10-repomap-ts.md` §1 names the commands):
53
+ *
54
+ * 7. **`sum()` over floats is COMPENSATED in CPython and is not in JS.** Measured:
55
+ * `sum([0.1, 0.2, 0.3, 1e16, -1e16])` is `0.6` in CPython, `0.0` from an explicit
56
+ * CPython loop, and `0` from both a `for` loop and `Array.reduce` in node. So the
57
+ * PYTHON side must never call `sum()` on floats, and this side must never introduce a
58
+ * `reduce` that looks like tidier code. Every accumulation in `pagerank` below is a
59
+ * written-out loop, in the same order.
60
+ * 8. **`float` -> `str` disagrees in FORM.** `repr(1.0)`/`String(1.0)` are `'1.0'`/`'1'`;
61
+ * `1e-07`/`1e-7`; `1e+16`/`10000000000000000`. **No raw float is ever rendered.**
62
+ * `RepoMap.text` carries no score and the structured result carries `rankUnits`, an
63
+ * integer.
64
+ * 9. **`round()` is banker's in CPython and half-up in JS.** Measured: `round(0.5)` is 0
65
+ * and 1, `round(2.5)` is 2 and 3, `round(-1.5)` is -2 and -1. Never used. `rankUnits`
66
+ * is `Math.floor(score * SCORE_SCALE)`, and `math.floor`/`Math.floor` agreed on every
67
+ * probed value including negatives (`[0.5, 1.5, 2.5, -0.5, -1.5, -2.5]` ->
68
+ * `0 1 2 -1 -2 -3` on both).
69
+ * 10. **`sorted()` on `str` is code-point order; JS's default `Array.sort` is UTF-16
70
+ * code-UNIT order.** Measured: `['\u{1F414}.md', '!.md']` sorts chicken-first in JS
71
+ * and chicken-last in CPython. Every sort here routes through `cmpCodepoint`, which is
72
+ * `pysem.ts`'s existing pin and is `_by_code_point`'s tuple comparison exactly.
73
+ *
74
+ * ---------------------------------------------------------------------------------------
75
+ * THREE THINGS CPYTHON GAVE `repomap.py` FOR FREE AND THIS FILE HAS TO WRITE OUT
76
+ * ---------------------------------------------------------------------------------------
77
+ * J45-9 mutation-tested its own module and recorded four survivors it could not kill from
78
+ * Python. Three of them are load-bearing HERE, which is why they are called out rather
79
+ * than translated line for line:
80
+ *
81
+ * a. **The symlink filter.** `os.walk(followlinks=False)` refuses to descend on its own,
82
+ * so deleting the explicit `islink` filter changed nothing in CPython. Node has no
83
+ * such flag, and worse: `readdirSync(..., {withFileTypes: true})` reports a Dirent
84
+ * from **lstat**, so a symlink to a directory answers `isDirectory() === false` and a
85
+ * naive port files it as a SOURCE FILE. Measured on node v25.2.1, a directory holding
86
+ * `real/`, a `linkdir -> real` symlink, a `linkfile.py -> plain.py` symlink and a
87
+ * dangling `dangling.py`: every symlink answers `dirent.isDirectory=false,
88
+ * dirent.isSymbolicLink=true`, and only a follow-`stat` separates the three. CPython's
89
+ * `os.walk` puts `linkdir` in `dirs` (its `entry.is_dir()` FOLLOWS) and then declines
90
+ * to descend, and reports `dangling.py` and `linkfile.py` as files. `walkSources`
91
+ * below reproduces that split with a follow-`stat`, not with the Dirent.
92
+ * b. **The by-path tie-break.** EQUIVALENT in CPython — `list.sort` is stable and the
93
+ * pre-sort order is already code-point path order — and it is copied anyway, because
94
+ * copying it is what removes the dependency on sort stability rather than relying on
95
+ * `Array.prototype.sort` having become stable in ES2019.
96
+ * c. **`cmpCodepoint`.** UNKILLABLE from a Python fixture, by construction: CPython's
97
+ * `str` comparison IS code-point comparison. It exists entirely for this side.
98
+ *
99
+ * ---------------------------------------------------------------------------------------
100
+ * THE BUDGET IS COUNTED IN UTF-8 BYTES, AND THAT IS STATED RATHER THAN CONVERTED
101
+ * ---------------------------------------------------------------------------------------
102
+ * Row 10 says "1 K-token budget". Neither runtime can honour that unit honestly: the
103
+ * exported `tokens()` (`memory/store.ts`) is an ASCII word split returning a SET — a
104
+ * similarity primitive, not a counter — and no model tokenizer exists here, nor may one be
105
+ * added. So the budget is stated, enforced and reported in UTF-8 bytes and `DEFAULT_BUDGET`
106
+ * is 4000, which is 1 K tokens at the char/4 convention `tools/hooks/bantamkit-hook.mjs`
107
+ * already uses. That convention is an ESTIMATE whose error bar is not measured, because
108
+ * measuring it needs the tokenizer this runtime is not allowed to have. Bytes are what is
109
+ * enforced and no sentence anywhere converts back.
110
+ *
111
+ * The omission footer is deliberately NOT charged to the budget. A budget that can suppress
112
+ * the disclosure of what it dropped is the defect the footer exists to close.
113
+ */
114
+ import { readdirSync, statSync } from 'node:fs';
115
+ import { join } from 'node:path';
116
+ import { pyReadText } from './memory/pyfs.js';
117
+ import { cmpCodepoint } from './pysem.js';
118
+ // --- pinned constants -------------------------------------------------------------
119
+ // Every one of these is measured in the header above. A port that changes one changes the
120
+ // ranking, so they are named rather than inlined.
121
+ export const DAMPING = 0.2;
122
+ export const ITERATIONS = 30;
123
+ export const MIN_REFERENCE_LENGTH = 3;
124
+ // A name referenced by more than NUM/DEN of the scanned files makes no edge. Compared as
125
+ // integers on both sides so the threshold cannot drift on a float.
126
+ export const REFERENCE_DF_MAX_NUM = 1;
127
+ export const REFERENCE_DF_MAX_DEN = 8;
128
+ // At most this many definitions of any one file reach the listing. See the header: without
129
+ // it a 4000-byte map over this repository holds 6.1 files.
130
+ export const MAX_DEFINITIONS_PER_FILE = 4;
131
+ export const DEFAULT_BUDGET = 4000;
132
+ export const SCORE_SCALE = 1_000_000_000;
133
+ // A source file larger than this is not scanned. 1 MiB; the largest tracked source in this
134
+ // repository is well under it, so the cap is a guard against a vendored bundle rather than
135
+ // a working limit, and a file it stops is REPORTED, never dropped.
136
+ export const MAX_FILE_BYTES = 1_048_576;
137
+ export const SKIP_DIRS = [
138
+ '.git',
139
+ '.hg',
140
+ '.svn',
141
+ '.mypy_cache',
142
+ '.pytest_cache',
143
+ '.ruff_cache',
144
+ '.tox',
145
+ '.venv',
146
+ '__pycache__',
147
+ 'build',
148
+ 'dist',
149
+ 'node_modules',
150
+ 'venv',
151
+ ];
152
+ const SKIP_DIR_SET = new Set(SKIP_DIRS);
153
+ /**
154
+ * Suffix -> dialect. Two dialects only: `python` and `ecma`. TypeScript and JavaScript
155
+ * share a scanner because their DECLARATION forms are the same set; the extra TypeScript
156
+ * keywords (`interface`, `type`, `enum`) are simply absent from a `.js` file.
157
+ */
158
+ export const LANGUAGES = {
159
+ '.cjs': 'ecma',
160
+ '.cts': 'ecma',
161
+ '.js': 'ecma',
162
+ '.jsx': 'ecma',
163
+ '.mjs': 'ecma',
164
+ '.mts': 'ecma',
165
+ '.py': 'python',
166
+ '.pyi': 'python',
167
+ '.ts': 'ecma',
168
+ '.tsx': 'ecma',
169
+ };
170
+ export const OMIT_UNKNOWN_LANGUAGE = 'unknown-language';
171
+ export const OMIT_UNREADABLE = 'unreadable-bytes';
172
+ export const OMIT_SIZE_CAP = 'size-cap';
173
+ export const OMIT_NO_DEFINITIONS = 'no-definitions';
174
+ export const OMIT_UNREACHABLE = 'unreachable';
175
+ export const OMIT_PER_FILE_CAP = 'per-file-cap';
176
+ export const OMIT_BUDGET = 'budget';
177
+ /**
178
+ * Fixed render order for the footer, so two runs of the same map cannot disagree about the
179
+ * order of their disclosures.
180
+ */
181
+ export const OMISSION_ORDER = [
182
+ OMIT_UNKNOWN_LANGUAGE,
183
+ OMIT_UNREADABLE,
184
+ OMIT_SIZE_CAP,
185
+ OMIT_NO_DEFINITIONS,
186
+ OMIT_UNREACHABLE,
187
+ OMIT_PER_FILE_CAP,
188
+ OMIT_BUDGET,
189
+ ];
190
+ const ECMA_KEYWORDS = [
191
+ 'function',
192
+ 'class',
193
+ 'interface',
194
+ 'type',
195
+ 'const',
196
+ 'let',
197
+ 'var',
198
+ 'enum',
199
+ ];
200
+ function omission(subject, count, size = 0, facts = []) {
201
+ return { subject, count, size, facts };
202
+ }
203
+ /**
204
+ * `sorted(values, key=_by_code_point)` — trap (10).
205
+ *
206
+ * `_by_code_point` returns a tuple of ordinals and Python compares tuples element-wise then
207
+ * by length; `cmpCodepoint` is that comparison written out, and it is already the pin every
208
+ * other module in this package sorts through. One comparator, not a rule to remember.
209
+ */
210
+ function sortedByCodePoint(values) {
211
+ return [...values].sort(cmpCodepoint);
212
+ }
213
+ /**
214
+ * The dialect for a path, or `null` when this scanner has no reader for it.
215
+ *
216
+ * Dispatch is on SUFFIX, and that is a deliberate departure from `docread`, which sniffs
217
+ * because a suffix is measurably a lie about a document's container. A source file is
218
+ * different: `.py` and `.ts` name a GRAMMAR, and there is no magic number for a grammar. A
219
+ * file whose suffix is unknown is reported, never guessed at.
220
+ */
221
+ export function languageOf(path) {
222
+ const dot = path.lastIndexOf('.');
223
+ const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
224
+ if (dot < 0 || dot < slash)
225
+ return null;
226
+ const found = LANGUAGES[path.slice(dot).toLowerCase()];
227
+ return found === undefined ? null : found;
228
+ }
229
+ /**
230
+ * The file's lines with comments and docstrings blanked, one output line per input line so
231
+ * line numbers survive.
232
+ *
233
+ * Line-oriented and stateful across lines, because both traps that matter span lines: a
234
+ * Python triple-quoted docstring and an ECMAScript block comment. What is removed:
235
+ *
236
+ * - `python` — `'''`/`"""` blocks (the delimiter that opens is the only one that can close),
237
+ * and everything from an unquoted `#` to end of line.
238
+ * - `ecma` — block comments, and everything from `//` to end of line.
239
+ *
240
+ * What is NOT removed, and is declared rather than hidden: the contents of a SINGLE-line
241
+ * string literal. Stripping those would lose the real references inside an f-string, and
242
+ * keeping them costs a definition-shaped line inside a one-line string being read as a
243
+ * definition. Backslash escapes are NOT honoured: a `"""` inside a string toggles the state
244
+ * machine. Both runtimes get that wrong identically, which is the requirement.
245
+ */
246
+ export function stripNoncode(text, language) {
247
+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
248
+ const out = [];
249
+ let delim = '';
250
+ for (const raw of lines) {
251
+ let line = raw;
252
+ if (language === 'python') {
253
+ if (delim) {
254
+ const at = line.indexOf(delim);
255
+ if (at < 0) {
256
+ out.push('');
257
+ continue;
258
+ }
259
+ line = line.slice(at + 3);
260
+ delim = '';
261
+ }
262
+ for (;;) {
263
+ const double = line.indexOf('"""');
264
+ const single = line.indexOf("'''");
265
+ if (double < 0 && single < 0)
266
+ break;
267
+ let at;
268
+ if (double < 0)
269
+ at = single;
270
+ else if (single < 0)
271
+ at = double;
272
+ else
273
+ at = double < single ? double : single;
274
+ const opener = line.slice(at, at + 3);
275
+ const close = line.indexOf(opener, at + 3);
276
+ if (close < 0) {
277
+ delim = opener;
278
+ line = line.slice(0, at);
279
+ break;
280
+ }
281
+ line = line.slice(0, at) + ' ' + line.slice(close + 3);
282
+ }
283
+ const hashAt = line.indexOf('#');
284
+ if (hashAt >= 0)
285
+ line = line.slice(0, hashAt);
286
+ }
287
+ else {
288
+ if (delim) {
289
+ const at = line.indexOf('*/');
290
+ if (at < 0) {
291
+ out.push('');
292
+ continue;
293
+ }
294
+ line = line.slice(at + 2);
295
+ delim = '';
296
+ }
297
+ for (;;) {
298
+ const at = line.indexOf('/*');
299
+ if (at < 0)
300
+ break;
301
+ const close = line.indexOf('*/', at + 2);
302
+ if (close < 0) {
303
+ delim = '*/';
304
+ line = line.slice(0, at);
305
+ break;
306
+ }
307
+ line = line.slice(0, at) + ' ' + line.slice(close + 2);
308
+ }
309
+ const slashAt = line.indexOf('//');
310
+ if (slashAt >= 0)
311
+ line = line.slice(0, slashAt);
312
+ }
313
+ out.push(line);
314
+ }
315
+ return out;
316
+ }
317
+ function isNameStart(ch) {
318
+ return ch === '_' || ch === '$' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
319
+ }
320
+ function isNameChar(ch) {
321
+ return isNameStart(ch) || (ch >= '0' && ch <= '9');
322
+ }
323
+ /**
324
+ * The identifier starting at `at`, or `''`.
325
+ *
326
+ * Hand-rolled rather than a regex on purpose. `re` and `RegExp` agree on this character
327
+ * class, but a scan written out cannot pick up a dialect difference later, and the same
328
+ * twenty lines are the same twenty lines on both sides. ASCII only, exactly as `tokens()`
329
+ * is: a Python identifier may hold non-ASCII and this scanner will not see it. That is a
330
+ * declared limit and not a defect for one runtime to fix.
331
+ *
332
+ * Indexing is by UTF-16 code unit here and by code POINT in CPython, and that is safe only
333
+ * because every character this function can accept is ASCII: `isNameStart` refuses the lead
334
+ * surrogate of an astral character, so the scan stops at the same place on both sides. The
335
+ * sorts are a different question and go through `cmpCodepoint` — trap (10).
336
+ */
337
+ function nameAt(line, at) {
338
+ if (at >= line.length || !isNameStart(line.charAt(at)))
339
+ return '';
340
+ let end = at + 1;
341
+ while (end < line.length && isNameChar(line.charAt(end)))
342
+ end += 1;
343
+ return line.slice(at, end);
344
+ }
345
+ function skipBlank(line, at) {
346
+ let i = at;
347
+ while (i < line.length && (line.charAt(i) === ' ' || line.charAt(i) === '\t'))
348
+ i += 1;
349
+ return i;
350
+ }
351
+ /** `at` advanced past `word` and the whitespace after it, or -1. */
352
+ function wordThenBlank(line, at, word) {
353
+ const end = at + word.length;
354
+ if (line.slice(at, end) !== word)
355
+ return -1;
356
+ const after = skipBlank(line, end);
357
+ return after > end ? after : -1;
358
+ }
359
+ /**
360
+ * Every definition in `text`, in source order.
361
+ *
362
+ * `python` — `def` and `class` at ANY indentation, so a method counts. Methods are the
363
+ * signal that made the difference in measurement: a focus on `memory/dream.py` reaches
364
+ * `memory/store.py` through `_write_fact`, `_fact_path` and `_check_index_budget`, none of
365
+ * which is top-level. Plus a bare `NAME =` or `NAME: T =` at column 0, which is how this
366
+ * repository declares its constants.
367
+ *
368
+ * `ecma` — `function class interface type const let var enum` at COLUMN 0 only, optionally
369
+ * behind `export` and `default` and `async`. Restricting to column 0 was the second-largest
370
+ * measured improvement in this module (recall@10 0.361 -> 0.410 on its own): an indented
371
+ * `const` is a local, and `runtime-ts/src/docread.ts` was contributing 452 of them.
372
+ *
373
+ * The asymmetry is real and is not an oversight: an ECMAScript class member cannot be told
374
+ * from a call at the same indentation without tracking braces, and a scanner that guessed
375
+ * would invent definitions. Python's `def` keyword makes the same question free.
376
+ */
377
+ export function scanDefinitions(text, language, path = '') {
378
+ const definitions = [];
379
+ const seen = new Set();
380
+ const lines = stripNoncode(text, language);
381
+ for (let index = 0; index < lines.length; index += 1) {
382
+ const line = lines[index];
383
+ const number = index + 1;
384
+ if (language === 'python') {
385
+ let at = skipBlank(line, 0);
386
+ const head = at;
387
+ let after = wordThenBlank(line, at, 'async');
388
+ if (after > 0)
389
+ at = after;
390
+ for (const keyword of ['def', 'class']) {
391
+ after = wordThenBlank(line, at, keyword);
392
+ if (after < 0)
393
+ continue;
394
+ const name = nameAt(line, after);
395
+ const key = `${number}:${name}`;
396
+ if (name && !seen.has(key)) {
397
+ seen.add(key);
398
+ definitions.push({ path, line: number, kind: keyword, name });
399
+ }
400
+ break;
401
+ }
402
+ if (head !== 0)
403
+ continue;
404
+ const name = nameAt(line, 0);
405
+ if (!name)
406
+ continue;
407
+ at = skipBlank(line, name.length);
408
+ if (at < line.length && line.charAt(at) === ':') {
409
+ // `NAME: T = ...` — the annotation may hold anything but `=`.
410
+ at += 1;
411
+ while (at < line.length && line.charAt(at) !== '=')
412
+ at += 1;
413
+ }
414
+ if (at >= line.length || line.charAt(at) !== '=')
415
+ continue;
416
+ if (at + 1 < line.length && line.charAt(at + 1) === '=')
417
+ continue;
418
+ const key = `${number}:${name}`;
419
+ if (!seen.has(key)) {
420
+ seen.add(key);
421
+ definitions.push({ path, line: number, kind: 'const', name });
422
+ }
423
+ }
424
+ else {
425
+ const first = line.slice(0, 1);
426
+ if (first === ' ' || first === '\t')
427
+ continue;
428
+ let at = 0;
429
+ for (const prefix of ['export', 'default', 'async']) {
430
+ const after = wordThenBlank(line, at, prefix);
431
+ if (after > 0)
432
+ at = after;
433
+ }
434
+ for (const keyword of ECMA_KEYWORDS) {
435
+ const after = wordThenBlank(line, at, keyword);
436
+ if (after < 0)
437
+ continue;
438
+ const name = nameAt(line, after);
439
+ const key = `${number}:${name}`;
440
+ if (name && !seen.has(key)) {
441
+ seen.add(key);
442
+ definitions.push({ path, line: number, kind: keyword, name });
443
+ }
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ return definitions;
449
+ }
450
+ /**
451
+ * Every identifier of at least `MIN_REFERENCE_LENGTH` characters outside a comment.
452
+ *
453
+ * A SET, not a count. The edge weight below is "how many DISTINCT names of B does A
454
+ * mention", so a file that repeats one name a hundred times does not out-weigh a file that
455
+ * mentions ten. That is the choice the ambiguity filter and `MIN_REFERENCE_LENGTH` were
456
+ * tuned against and it keeps every weight an exact integer.
457
+ */
458
+ export function referenceNames(text, language) {
459
+ const names = new Set();
460
+ for (const line of stripNoncode(text, language)) {
461
+ let at = 0;
462
+ const width = line.length;
463
+ while (at < width) {
464
+ if (!isNameStart(line.charAt(at))) {
465
+ at += 1;
466
+ continue;
467
+ }
468
+ const name = nameAt(line, at);
469
+ at += name.length;
470
+ if (name.length >= MIN_REFERENCE_LENGTH)
471
+ names.add(name);
472
+ }
473
+ }
474
+ return names;
475
+ }
476
+ /**
477
+ * Every path under `root`, relative, POSIX, code-point sorted.
478
+ *
479
+ * DIRECTORY SYMLINKS ARE NOT FOLLOWED, AND ON THIS SIDE THAT IS REAL LOGIC RATHER THAN A
480
+ * FLAG. `os.walk(followlinks=False)` gives CPython the whole rule for free, and J45-9's
481
+ * mutation 24 — delete the explicit `islink` filter — SURVIVED there for exactly that
482
+ * reason. Node has no equivalent, and the Dirent that looks like the answer is the wrong
483
+ * one: `readdirSync(..., {withFileTypes: true})` reports from **lstat**, so a symlink to a
484
+ * directory answers `isDirectory() === false` and a naive port hands it to the scanner as a
485
+ * source FILE. CPython does the opposite — `os.scandir`'s `entry.is_dir()` FOLLOWS, so the
486
+ * link lands in `dirs`, and `followlinks=False` then declines to descend, so it appears in
487
+ * the output neither as a file nor as a directory.
488
+ *
489
+ * Measured on node v25.2.1 and CPython 3.12.13 over one tree holding `real/`,
490
+ * `linkdir -> real`, `linkfile.py -> plain.py` and a dangling `dangling.py`: CPython's walk
491
+ * yields `dirs=['linkdir', 'real'] files=['dangling.py', 'linkfile.py', 'plain.py']` and
492
+ * `walk_sources` returns `['dangling.py', 'linkfile.py', 'plain.py', 'real/a.py']`. So the
493
+ * three cases are separated by a FOLLOW-`stat`, not by the Dirent:
494
+ *
495
+ * - symlink whose target is a directory -> a directory, and not descended into: gone.
496
+ * - symlink whose target is a file -> a file: kept.
497
+ * - dangling symlink -> `stat` throws, CPython's `is_dir()` raises and
498
+ * is caught as False: a file, kept, and it will
499
+ * be counted as `unreadable-bytes` downstream.
500
+ *
501
+ * Two reasons the rule exists and both are measured elsewhere in this repository: a symlink
502
+ * cycle makes the walk non-terminating, and a dangling directory symlink is one error shape
503
+ * on POSIX and two on Windows (`reference-windows-dangling-symlink-two-shapes`).
504
+ *
505
+ * A directory whose listing throws is skipped whole, silently, because that is what
506
+ * `os.walk`'s default `onerror=None` does.
507
+ */
508
+ export function walkSources(root) {
509
+ const found = [];
510
+ const descend = (dir, prefix) => {
511
+ let entries;
512
+ try {
513
+ entries = readdirSync(dir, { withFileTypes: true });
514
+ }
515
+ catch {
516
+ return;
517
+ }
518
+ const dirs = [];
519
+ const files = [];
520
+ for (const entry of entries) {
521
+ const name = entry.name;
522
+ let isDir;
523
+ const isLink = entry.isSymbolicLink();
524
+ if (isLink) {
525
+ try {
526
+ isDir = statSync(join(dir, name)).isDirectory();
527
+ }
528
+ catch {
529
+ isDir = false;
530
+ }
531
+ }
532
+ else {
533
+ isDir = entry.isDirectory();
534
+ }
535
+ if (isDir) {
536
+ if (!isLink && !SKIP_DIR_SET.has(name))
537
+ dirs.push(name);
538
+ continue;
539
+ }
540
+ files.push(name);
541
+ }
542
+ for (const name of sortedByCodePoint(files))
543
+ found.push(prefix + name);
544
+ for (const name of sortedByCodePoint(dirs))
545
+ descend(join(dir, name), `${prefix}${name}/`);
546
+ };
547
+ descend(root, '');
548
+ return sortedByCodePoint(found);
549
+ }
550
+ function scanTree(root, candidates) {
551
+ const out = {
552
+ paths: [],
553
+ definitions: new Map(),
554
+ references: new Map(),
555
+ omissions: [],
556
+ };
557
+ let unknown = 0;
558
+ let unknownBytes = 0;
559
+ let unreadable = 0;
560
+ let unreadableBytes = 0;
561
+ let oversize = 0;
562
+ let oversizeBytes = 0;
563
+ let empty = 0;
564
+ for (const rel of candidates) {
565
+ const language = languageOf(rel);
566
+ const full = join(root, rel);
567
+ let size = 0;
568
+ try {
569
+ size = statSync(full).size;
570
+ }
571
+ catch {
572
+ size = 0;
573
+ }
574
+ if (language === null) {
575
+ unknown += 1;
576
+ unknownBytes += size;
577
+ continue;
578
+ }
579
+ if (size > MAX_FILE_BYTES) {
580
+ oversize += 1;
581
+ oversizeBytes += size;
582
+ continue;
583
+ }
584
+ let text;
585
+ try {
586
+ // `pyReadText` is `Path.read_text(encoding="utf-8")`: STRICT UTF-8 (node's own
587
+ // `readFileSync(p, 'utf8')` substitutes U+FFFD and would make an unreadable file
588
+ // scan clean) plus the universal-newline fold CPython's text mode applies.
589
+ text = pyReadText(full);
590
+ }
591
+ catch {
592
+ unreadable += 1;
593
+ unreadableBytes += size;
594
+ continue;
595
+ }
596
+ const found = scanDefinitions(text, language, rel);
597
+ out.paths.push(rel);
598
+ out.definitions.set(rel, found);
599
+ out.references.set(rel, referenceNames(text, language));
600
+ if (found.length === 0)
601
+ empty += 1;
602
+ }
603
+ if (unknown)
604
+ out.omissions.push(omission(OMIT_UNKNOWN_LANGUAGE, unknown, unknownBytes));
605
+ if (unreadable)
606
+ out.omissions.push(omission(OMIT_UNREADABLE, unreadable, unreadableBytes));
607
+ if (oversize) {
608
+ out.omissions.push(omission(OMIT_SIZE_CAP, oversize, oversizeBytes, [['cap_bytes', MAX_FILE_BYTES]]));
609
+ }
610
+ if (empty)
611
+ out.omissions.push(omission(OMIT_NO_DEFINITIONS, empty));
612
+ return out;
613
+ }
614
+ /**
615
+ * `[definer, frequency]` over the names that are allowed to carry file identity.
616
+ *
617
+ * A name survives two filters and they catch different failures. The AMBIGUITY filter drops
618
+ * a name defined in more than one file — `main` is defined in 51 of this repository's files
619
+ * and `__init__` in 39, and letting those through costs recall@10 0.614 -> 0.506. **The
620
+ * "exactly one definer" rule is deliberately NOT a named constant**: it was one in an
621
+ * earlier draft, nothing read it, and a number the code ignores is a lie about what is
622
+ * configurable. The document-frequency filter drops a name REFERENCED by more than an
623
+ * eighth of the files, which is what stops a top-level `const str` in one file from
624
+ * collecting an edge from every Python file in the tree.
625
+ *
626
+ * The second return is the RAW reference count for every name, unfiltered, because it
627
+ * answers a different question and the two answers must not be conflated. The edge question
628
+ * is "which file does this name IDENTIFY?" and both filters serve it. The RENDERING
629
+ * question is "what is this file best known BY?", and there a widely-referenced name is
630
+ * exactly the one worth the bytes. Running the filters over both was measured and it is
631
+ * wrong for the second: `class MemoryStore` is referenced by 34 of 275 files and defined in
632
+ * two of them (`memory/store.py` and its port `memory/store.ts`), so BOTH filters reject it
633
+ * — and `memory/store.py`'s block came out as `headroom`, `lint`, `archived`, `archive`,
634
+ * with the class the file exists for nowhere in the map.
635
+ */
636
+ function nameIndex(definitions, references) {
637
+ const files = references.size;
638
+ const frequency = new Map();
639
+ for (const path of sortedByCodePoint(references.keys())) {
640
+ for (const name of references.get(path)) {
641
+ frequency.set(name, (frequency.get(name) ?? 0) + 1);
642
+ }
643
+ }
644
+ const definer = new Map();
645
+ const ambiguous = new Set();
646
+ for (const path of sortedByCodePoint(definitions.keys())) {
647
+ for (const definition of definitions.get(path)) {
648
+ const name = definition.name;
649
+ if (ambiguous.has(name))
650
+ continue;
651
+ const held = definer.get(name);
652
+ if (held === undefined) {
653
+ definer.set(name, path);
654
+ }
655
+ else if (held !== path) {
656
+ ambiguous.add(name);
657
+ definer.delete(name);
658
+ }
659
+ }
660
+ }
661
+ for (const name of sortedByCodePoint(definer.keys())) {
662
+ const seen = frequency.get(name) ?? 0;
663
+ // A FLOOR ON THE THRESHOLD, and it was found by the tests rather than reasoned about.
664
+ // A fraction of a tree smaller than the denominator is less than one file, so on a
665
+ // three-file project the eighth rejected every name that was mentioned at all and the
666
+ // map came back completely empty. A name fewer than `REFERENCE_DF_MAX_DEN` files
667
+ // mention is never "too common", whatever the fraction says. On this repository the
668
+ // threshold is 34 of 275 files and the floor is 8, so the floor is INERT here and
669
+ // changes no measured number above — it only rescues trees too small for a fraction to
670
+ // mean anything.
671
+ if (seen <= REFERENCE_DF_MAX_DEN)
672
+ continue;
673
+ if (seen * REFERENCE_DF_MAX_DEN > files * REFERENCE_DF_MAX_NUM)
674
+ definer.delete(name);
675
+ }
676
+ return [definer, frequency];
677
+ }
678
+ /**
679
+ * `source -> {target: weight}`, weight being the number of surviving names the source
680
+ * mentions that ONLY the target defines.
681
+ *
682
+ * Weights are exact integers, so `out[i]` in `pagerank` is an exact integer too and the only
683
+ * inexact step in the whole pass is the one division.
684
+ */
685
+ export function buildGraph(definitions, references) {
686
+ const [definer] = nameIndex(definitions, references);
687
+ const edges = new Map();
688
+ for (const path of sortedByCodePoint(references.keys())) {
689
+ const weights = new Map();
690
+ for (const name of sortedByCodePoint(references.get(path))) {
691
+ const target = definer.get(name);
692
+ if (target === undefined || target === path)
693
+ continue;
694
+ weights.set(target, (weights.get(target) ?? 0) + 1);
695
+ }
696
+ if (weights.size)
697
+ edges.set(path, weights);
698
+ }
699
+ return edges;
700
+ }
701
+ /**
702
+ * A definition's kind decides what it is worth saying about a file, before any count does.
703
+ * A type names the file's subject; a callable is its verb; a binding is a detail.
704
+ */
705
+ const KIND_RANK = {
706
+ class: 0,
707
+ interface: 0,
708
+ enum: 0,
709
+ type: 0,
710
+ def: 1,
711
+ function: 1,
712
+ const: 2,
713
+ let: 2,
714
+ var: 2,
715
+ };
716
+ const KIND_RANK_SIZE = Object.keys(KIND_RANK).length;
717
+ /**
718
+ * At most `limit` of a file's definitions, chosen by kind then reach, returned in SOURCE
719
+ * order.
720
+ *
721
+ * The key is `(kind rank, -reference count, line, name)`, and every part of it is an integer
722
+ * or a code-point comparison, so the choice is TOTAL — which is why this port does not lean
723
+ * on `Array.prototype.sort` being stable. `references` is the RAW count from `nameIndex`;
724
+ * see there for why it is not the filtered one.
725
+ *
726
+ * **This ordering is argued, not measured, and the difference matters.** Every other
727
+ * constant in this module beat a named alternative on a yardstick. This one cannot: the
728
+ * yardstick is "does the focus file's own import appear in the rendered map", which is a
729
+ * question about which FILES are listed, and selection only decides which lines appear UNDER
730
+ * a file already listed. Measured, it moves that number by zero. So it rests on a stated
731
+ * principle instead — name the file's subject before its details — and a later unit with a
732
+ * definition-level yardstick is entitled to overturn it. What it is NOT is source order,
733
+ * which put `VALID_TYPES`, `DURABLE_TYPES`, `NAME_RE` and `DUPLICATE_JACCARD` at the head of
734
+ * `memory/store.py`'s block.
735
+ *
736
+ * The selected definitions are re-sorted into SOURCE order for rendering: a block whose line
737
+ * numbers ran backwards would be harder to read for no gain, and the order a block is chosen
738
+ * in is not the order it is read in.
739
+ */
740
+ export function selectDefinitions(definitions, references, limit) {
741
+ if (limit <= 0 || definitions.length <= limit)
742
+ return definitions;
743
+ const ordered = [...definitions].sort((a, b) => {
744
+ const ka = KIND_RANK[a.kind] ?? KIND_RANK_SIZE;
745
+ const kb = KIND_RANK[b.kind] ?? KIND_RANK_SIZE;
746
+ if (ka !== kb)
747
+ return ka - kb;
748
+ const ra = -(references.get(a.name) ?? 0);
749
+ const rb = -(references.get(b.name) ?? 0);
750
+ if (ra !== rb)
751
+ return ra - rb;
752
+ if (a.line !== b.line)
753
+ return a.line - b.line;
754
+ return cmpCodepoint(a.name, b.name);
755
+ });
756
+ const chosen = ordered.slice(0, limit);
757
+ return chosen.sort((a, b) => (a.line !== b.line ? a.line - b.line : cmpCodepoint(a.name, b.name)));
758
+ }
759
+ /**
760
+ * Personalised PageRank, bit-identical to `repomap.py` by construction.
761
+ *
762
+ * The port contract, in the order the loop performs it:
763
+ *
764
+ * 1. `nodes` is already sorted; index by position in it.
765
+ * 2. `out[i]` is the exact integer sum of node i's out-weights.
766
+ * 3. Each node's out-edges are visited in sorted TARGET order.
767
+ * 4. The personalisation vector is `1/len(personal)` on each focus node and 0 elsewhere;
768
+ * with no focus it is `1/n` on every node. The INITIAL vector is that same vector.
769
+ * 5. Per round: zero `next`; walk sources in node order; a node with no out-edges adds its
770
+ * whole score to `dangling`; otherwise each out-edge adds `score[i] * weight / out[i]`,
771
+ * evaluated strictly left to right.
772
+ * 6. `tele = damping * dangling + (1.0 - damping)`, then
773
+ * `next[i] = next[i] * damping + tele * personal[i]`.
774
+ * 7. Exactly `iterations` rounds. No early stop, no convergence test — a branch on a float
775
+ * is one more thing for a port to get wrong, and the contraction bound (the linear part
776
+ * is `damping * column-stochastic`, so the residual after 30 rounds is under
777
+ * `2 * 0.2 ** 30` = 2.1e-21) makes the test unnecessary.
778
+ *
779
+ * THERE IS NO `reduce` HERE AND THERE MUST NOT BE ONE: every accumulation is a written-out
780
+ * loop in the same order as CPython's, because CPython's `sum()` is Neumaier-compensated and
781
+ * nothing in JavaScript is (trap 7). The Python side is held to the same rule by
782
+ * `test_no_float_summation_or_rounding_in_the_source`, which reads that function's body.
783
+ */
784
+ export function pagerank(nodes, edges, personal, damping = DAMPING, iterations = ITERATIONS) {
785
+ const count = nodes.length;
786
+ if (count === 0)
787
+ return [];
788
+ const index = new Map();
789
+ for (let i = 0; i < count; i += 1)
790
+ index.set(nodes[i], i);
791
+ const out = new Array(count).fill(0);
792
+ const adjacency = [];
793
+ for (let i = 0; i < count; i += 1)
794
+ adjacency.push([]);
795
+ for (const path of nodes) {
796
+ const source = index.get(path);
797
+ const row = edges.get(path);
798
+ if (row === undefined)
799
+ continue;
800
+ for (const target of sortedByCodePoint(row.keys())) {
801
+ const weight = row.get(target);
802
+ const at = index.get(target);
803
+ if (at === undefined)
804
+ continue;
805
+ adjacency[source].push([at, weight]);
806
+ out[source] = out[source] + weight;
807
+ }
808
+ }
809
+ const focus = [];
810
+ for (const p of personal)
811
+ if (index.has(p))
812
+ focus.push(p);
813
+ const vector = new Array(count).fill(0);
814
+ if (focus.length) {
815
+ const share = 1.0 / focus.length;
816
+ for (const path of focus)
817
+ vector[index.get(path)] = share;
818
+ }
819
+ else {
820
+ const share = 1.0 / count;
821
+ for (let i = 0; i < count; i += 1)
822
+ vector[i] = share;
823
+ }
824
+ let score = [...vector];
825
+ for (let round = 0; round < iterations; round += 1) {
826
+ const nxt = new Array(count).fill(0);
827
+ let dangling = 0.0;
828
+ for (let i = 0; i < count; i += 1) {
829
+ if (out[i] === 0) {
830
+ dangling += score[i];
831
+ continue;
832
+ }
833
+ for (const [target, weight] of adjacency[i]) {
834
+ nxt[target] = nxt[target] + (score[i] * weight) / out[i];
835
+ }
836
+ }
837
+ const tele = damping * dangling + (1.0 - damping);
838
+ for (let i = 0; i < count; i += 1)
839
+ nxt[i] = nxt[i] * damping + tele * vector[i];
840
+ score = nxt;
841
+ }
842
+ return score;
843
+ }
844
+ /**
845
+ * The listing, greedily filled in rank order, plus what it could not carry.
846
+ *
847
+ * A file block is its path on one line, then one indented line per definition in source
848
+ * order. A block that does not fit whole is included as far as it fits — the header plus the
849
+ * definitions that fit — because dropping a whole high-rank file for one line's overflow
850
+ * would hand back an empty map for a repository whose top file is large. A header with no
851
+ * definition under it is never emitted: it would spend bytes to say nothing.
852
+ *
853
+ * `Buffer.byteLength(s, 'utf8')` is `len(s.encode('utf-8'))`, including for the astral
854
+ * characters where a `.length` would have counted two UTF-16 units and CPython one
855
+ * character — neither of which is the byte count either side is spending.
856
+ */
857
+ function render(ranked, budget) {
858
+ const parts = [];
859
+ let used = 0;
860
+ let filesRendered = 0;
861
+ let definitionsRendered = 0;
862
+ let filesDropped = 0;
863
+ let definitionsDropped = 0;
864
+ let filesPartial = 0;
865
+ let stopped = false;
866
+ for (const entry of ranked) {
867
+ if (stopped) {
868
+ filesDropped += 1;
869
+ definitionsDropped += entry.definitions.length;
870
+ continue;
871
+ }
872
+ const header = entry.path;
873
+ const block = [];
874
+ let cost = Buffer.byteLength(header, 'utf8') + (parts.length ? 1 : 0);
875
+ let droppedHere = 0;
876
+ for (const definition of entry.definitions) {
877
+ const line = ` ${definition.line} ${definition.kind} ${definition.name}`;
878
+ const lineCost = Buffer.byteLength(line, 'utf8') + 1;
879
+ if (used + cost + lineCost > budget) {
880
+ droppedHere += 1;
881
+ continue;
882
+ }
883
+ block.push(line);
884
+ cost += lineCost;
885
+ }
886
+ if (block.length === 0) {
887
+ stopped = true;
888
+ filesDropped += 1;
889
+ definitionsDropped += entry.definitions.length;
890
+ continue;
891
+ }
892
+ if (parts.length)
893
+ parts.push('');
894
+ parts.push(header);
895
+ for (const line of block)
896
+ parts.push(line);
897
+ used += cost;
898
+ filesRendered += 1;
899
+ definitionsRendered += block.length;
900
+ if (droppedHere) {
901
+ filesPartial += 1;
902
+ definitionsDropped += droppedHere;
903
+ stopped = true;
904
+ }
905
+ }
906
+ return {
907
+ listing: parts.join('\n'),
908
+ used,
909
+ filesRendered,
910
+ definitionsRendered,
911
+ filesDropped,
912
+ definitionsDropped,
913
+ filesPartial,
914
+ };
915
+ }
916
+ /**
917
+ * `# omitted: subject=count ...` — counts, never adjectives, in `OMISSION_ORDER`.
918
+ *
919
+ * NOT charged to the budget, deliberately. A budget that could suppress the disclosure of
920
+ * what it dropped is the failure this line exists to close, and a footer that fits only when
921
+ * there is nothing to say would do exactly that. A subject with a zero count is not printed;
922
+ * a subject this module does not know cannot occur here, but a RENDERER that meets one must
923
+ * still print its count.
924
+ */
925
+ function footer(omissions) {
926
+ const bySubject = new Map();
927
+ for (const o of omissions)
928
+ bySubject.set(o.subject, o);
929
+ const known = new Set(OMISSION_ORDER);
930
+ const fields = [];
931
+ for (const subject of OMISSION_ORDER) {
932
+ const held = bySubject.get(subject);
933
+ if (held !== undefined && held.count)
934
+ fields.push(`${subject}=${held.count}`);
935
+ }
936
+ const extra = omissions
937
+ .filter((o) => !known.has(o.subject) && o.count)
938
+ .sort((a, b) => cmpCodepoint(a.subject, b.subject));
939
+ for (const o of extra)
940
+ fields.push(`${o.subject}=${o.count}`);
941
+ return fields.length ? '# omitted: ' + fields.join(' ') : '';
942
+ }
943
+ /**
944
+ * Scan `root`, rank its files against `focus`, and render a listing within `budget`.
945
+ *
946
+ * `focus` is the current unit's files, relative to `root` and POSIX-separated; a focus entry
947
+ * that is not a scanned source is ignored for the personalisation vector but is still
948
+ * excluded from the listing, because a caller naming a file has it open already. An empty
949
+ * focus gives the uniform vector — plain centrality over the whole tree.
950
+ *
951
+ * `budget` is UTF-8 BYTES of the listing. See the header: this runtime has no model
952
+ * tokenizer and will not pretend to one.
953
+ */
954
+ export function repoMap(root, options = {}) {
955
+ const focusIn = options.focus ?? [];
956
+ const budget = options.budget ?? DEFAULT_BUDGET;
957
+ const files = options.files ?? null;
958
+ const damping = options.damping ?? DAMPING;
959
+ const iterations = options.iterations ?? ITERATIONS;
960
+ const perFile = options.perFile ?? MAX_DEFINITIONS_PER_FILE;
961
+ const candidates = sortedByCodePoint(files !== null ? [...files] : walkSources(root));
962
+ const scanned = scanTree(root, candidates);
963
+ const nodes = scanned.paths;
964
+ const [, references] = nameIndex(scanned.definitions, scanned.references);
965
+ const edges = buildGraph(scanned.definitions, scanned.references);
966
+ const focusPaths = sortedByCodePoint(new Set(focusIn.map((f) => String(f))));
967
+ const scores = pagerank(nodes, edges, focusPaths, damping, iterations);
968
+ const focusSet = new Set(focusPaths);
969
+ const ranked = [];
970
+ let unreachable = 0;
971
+ let capped = 0;
972
+ for (let i = 0; i < nodes.length; i += 1) {
973
+ const path = nodes[i];
974
+ if (focusSet.has(path))
975
+ continue;
976
+ const score = scores[i];
977
+ if (score <= 0.0) {
978
+ unreachable += 1;
979
+ continue;
980
+ }
981
+ const definitions = scanned.definitions.get(path);
982
+ if (definitions.length === 0)
983
+ continue;
984
+ const shown = selectDefinitions(definitions, references, perFile);
985
+ capped += definitions.length - shown.length;
986
+ ranked.push({
987
+ path,
988
+ rankUnits: Math.floor(score * SCORE_SCALE),
989
+ definitions: shown,
990
+ score,
991
+ });
992
+ }
993
+ // The ONLY sort of the score vector, and it is over an INTEGER with an explicit by-path
994
+ // tie-break — traps (8) and (10). J45-9 measured dropping the tie-break as EQUIVALENT in
995
+ // CPython (`list.sort` is stable and the pre-sort order is already code-point path order)
996
+ // and it is written out here anyway, because writing it out is what removes the
997
+ // dependency on sort stability rather than relying on `Array.prototype.sort` having
998
+ // become stable in ES2019.
999
+ ranked.sort((a, b) => a.rankUnits !== b.rankUnits ? b.rankUnits - a.rankUnits : cmpCodepoint(a.path, b.path));
1000
+ const out = render(ranked, budget);
1001
+ const omissions = [...scanned.omissions];
1002
+ if (unreachable)
1003
+ omissions.push(omission(OMIT_UNREACHABLE, unreachable));
1004
+ if (capped)
1005
+ omissions.push(omission(OMIT_PER_FILE_CAP, capped, 0, [['per_file', perFile]]));
1006
+ if (out.filesDropped || out.definitionsDropped) {
1007
+ omissions.push(omission(OMIT_BUDGET, out.filesDropped, 0, [
1008
+ ['definitions', out.definitionsDropped],
1009
+ ['files_partial', out.filesPartial],
1010
+ ['budget_bytes', budget],
1011
+ ['listing_bytes', out.used],
1012
+ ]));
1013
+ }
1014
+ const foot = footer(omissions);
1015
+ let text = out.listing;
1016
+ if (foot)
1017
+ text = out.listing ? `${out.listing}\n${foot}` : foot;
1018
+ let edgeCount = 0;
1019
+ for (const row of edges.values())
1020
+ edgeCount += row.size;
1021
+ let definitionCount = 0;
1022
+ for (const found of scanned.definitions.values())
1023
+ definitionCount += found.length;
1024
+ return {
1025
+ text,
1026
+ ranked,
1027
+ omissions,
1028
+ focus: focusPaths,
1029
+ nodes: nodes.length,
1030
+ edges: edgeCount,
1031
+ definitions: definitionCount,
1032
+ damping,
1033
+ iterations,
1034
+ budget,
1035
+ perFile,
1036
+ listingBytes: out.used,
1037
+ filesRendered: out.filesRendered,
1038
+ definitionsRendered: out.definitionsRendered,
1039
+ };
1040
+ }