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.
- package/README.md +1 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/dist/eventlog.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/pyargs.d.ts +1 -1
- package/dist/mcp/pyargs.js +68 -0
- package/dist/mcp/server.d.ts +36 -6
- package/dist/mcp/server.js +152 -5
- package/dist/memory/cli.js +1 -1
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/store.d.ts +45 -1
- package/dist/memory/store.js +107 -2
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/package.json +1 -1
package/dist/repomap.js
ADDED
|
@@ -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
|
+
}
|