@maxgfr/codeindex 2.7.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 +115 -0
- package/docs/MIGRATION.md +100 -0
- package/package.json +89 -0
- package/scripts/cli.mjs +10 -0
- package/scripts/engine.d.mts +618 -0
- package/scripts/engine.mjs +10655 -0
- package/scripts/grammars/bash.wasm +0 -0
- package/scripts/grammars/c.wasm +0 -0
- package/scripts/grammars/c_sharp.wasm +0 -0
- package/scripts/grammars/cpp.wasm +0 -0
- package/scripts/grammars/go.wasm +0 -0
- package/scripts/grammars/java.wasm +0 -0
- package/scripts/grammars/javascript.wasm +0 -0
- package/scripts/grammars/lua.wasm +0 -0
- package/scripts/grammars/php.wasm +0 -0
- package/scripts/grammars/python.wasm +0 -0
- package/scripts/grammars/ruby.wasm +0 -0
- package/scripts/grammars/rust.wasm +0 -0
- package/scripts/grammars/scala.wasm +0 -0
- package/scripts/grammars/tsx.wasm +0 -0
- package/scripts/grammars/typescript.wasm +0 -0
- package/scripts/grammars/web-tree-sitter.wasm +0 -0
- package/src/ast/extract.ts +713 -0
- package/src/ast/loader.ts +104 -0
- package/src/bm25.ts +156 -0
- package/src/callers.ts +0 -0
- package/src/calls.ts +131 -0
- package/src/categorize.ts +80 -0
- package/src/centrality.ts +148 -0
- package/src/classify.ts +53 -0
- package/src/cli-entry.ts +16 -0
- package/src/community.ts +345 -0
- package/src/complexity.ts +69 -0
- package/src/coupling.ts +0 -0
- package/src/deadcode.ts +46 -0
- package/src/edit.ts +93 -0
- package/src/engine-cli.ts +278 -0
- package/src/engine.ts +148 -0
- package/src/extract/code.ts +376 -0
- package/src/extract/markdown.ts +135 -0
- package/src/git.ts +205 -0
- package/src/glob.ts +56 -0
- package/src/graph.ts +0 -0
- package/src/grep.ts +115 -0
- package/src/hash.ts +13 -0
- package/src/ignore.ts +134 -0
- package/src/lang/c.ts +26 -0
- package/src/lang/common.ts +68 -0
- package/src/lang/csharp.ts +22 -0
- package/src/lang/elixir.ts +18 -0
- package/src/lang/go.ts +22 -0
- package/src/lang/java.ts +19 -0
- package/src/lang/js-ts.ts +112 -0
- package/src/lang/kotlin.ts +20 -0
- package/src/lang/lua.ts +18 -0
- package/src/lang/php.ts +24 -0
- package/src/lang/python.ts +22 -0
- package/src/lang/registry.ts +54 -0
- package/src/lang/ruby.ts +17 -0
- package/src/lang/rust.ts +22 -0
- package/src/lang/scala.ts +19 -0
- package/src/lang/shell.ts +17 -0
- package/src/lang/swift.ts +23 -0
- package/src/mcp.ts +512 -0
- package/src/memory.ts +75 -0
- package/src/modules.ts +128 -0
- package/src/pipeline.ts +59 -0
- package/src/query.ts +118 -0
- package/src/render/graph-json.ts +16 -0
- package/src/render/symbols-json.ts +79 -0
- package/src/repomap.ts +52 -0
- package/src/resolve.ts +950 -0
- package/src/rules.ts +249 -0
- package/src/scan.ts +172 -0
- package/src/sort.ts +12 -0
- package/src/surprise.ts +58 -0
- package/src/tests-map.ts +105 -0
- package/src/types.ts +201 -0
- package/src/util.ts +169 -0
- package/src/viz.ts +44 -0
- package/src/walk.ts +216 -0
- package/src/workspaces.ts +748 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { CodeSymbol, RawRef } from "../types.js";
|
|
2
|
+
import { extractSymbols } from "../lang/registry.js";
|
|
3
|
+
import { extractAst } from "../ast/extract.js";
|
|
4
|
+
|
|
5
|
+
export interface CodeInfo {
|
|
6
|
+
symbols: CodeSymbol[];
|
|
7
|
+
summary?: string;
|
|
8
|
+
refs: RawRef[]; // import refs (raw specifiers, unresolved)
|
|
9
|
+
pkg?: string; // Java: the file's own `package x.y.z;` — used to derive source roots
|
|
10
|
+
idents?: string[]; // distinctive identifiers referenced (AST path) — feeds `use` edges
|
|
11
|
+
// Call-site callee names (+ immediate receiver for qualified calls) — feeds
|
|
12
|
+
// call edges and receiver-gated sink catalogs.
|
|
13
|
+
calls?: { name: string; line: number; receiver?: string }[];
|
|
14
|
+
importedNames?: string[]; // JS/TS named-import bindings (AST path) — feeds the call gate
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const JS_TS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
18
|
+
const PY = new Set([".py", ".pyi"]);
|
|
19
|
+
const C_CPP = new Set([".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"]);
|
|
20
|
+
|
|
21
|
+
// Tooling pragmas and boilerplate that are technically the first comment but say
|
|
22
|
+
// nothing about what the file does — never use them as a summary.
|
|
23
|
+
const DIRECTIVE_RE =
|
|
24
|
+
/^(eslint\b|eslint-|prettier\b|prettier-|tslint\b|jshint\b|jslint\b|globals?\b|istanbul\b|c8\s|v8\s|@ts-|ts-|@flow\b|@jsx\b|@jsxRuntime\b|@jest-environment\b|@vitest-environment\b|@license\b|@preserve\b|@copyright\b|copyright\b|spdx-|<reference\b|use strict|biome-|deno-lint|noqa\b|type:\s*ignore|pylint:|flake8:|mypy:|coding[:=])/i;
|
|
25
|
+
|
|
26
|
+
function isDirective(line: string): boolean {
|
|
27
|
+
return DIRECTIVE_RE.test(line.trim());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// License / banner boilerplate common in minified-library preambles (the `/*!`
|
|
31
|
+
// "preserve" banner of Express, jQuery, Bootstrap, Lodash, moment, …): a license
|
|
32
|
+
// name or a "released under"/URL line, not a description of what the file does.
|
|
33
|
+
// "Copyright" and "@license" are already caught by DIRECTIVE_RE.
|
|
34
|
+
const BANNER_RE =
|
|
35
|
+
/^((?:mit|isc|bsd|apache|gnu|gpl|mpl|lgpl|agpl)\s+licen[sc]ed?\b|licen[sc]ed\b|(?:released|distributed)\s+under\b|all rights reserved\b|https?:\/\/|www\.)/i;
|
|
36
|
+
|
|
37
|
+
function isBanner(line: string): boolean {
|
|
38
|
+
return BANNER_RE.test(line.trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The leading comment block of a file, turned into one summary line. Handles
|
|
42
|
+
// `//`, `#`, and `/* … */` / `""" … """` openers. Stops at the first code line.
|
|
43
|
+
function topDocComment(content: string): string | undefined {
|
|
44
|
+
const lines = content.split(/\r?\n/);
|
|
45
|
+
const collected: string[] = [];
|
|
46
|
+
let inBlock: "c" | "py" | null = null;
|
|
47
|
+
for (let i = 0; i < Math.min(lines.length, 40); i++) {
|
|
48
|
+
const raw = lines[i]!;
|
|
49
|
+
const line = raw.trim();
|
|
50
|
+
if (inBlock === "c") {
|
|
51
|
+
// Strip the closing `*/` BEFORE the leading `*`s, so a lone `*/` (or a line
|
|
52
|
+
// ending in `*/`) doesn't leave a stray "/" once the leading star is gone.
|
|
53
|
+
collected.push(line.replace(/\*+\/\s*$/, "").replace(/^\*+/, "").trim());
|
|
54
|
+
if (line.includes("*/")) inBlock = null;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inBlock === "py") {
|
|
58
|
+
if (line.includes('"""') || line.includes("'''")) {
|
|
59
|
+
collected.push(line.replace(/['"]{3}.*$/, "").trim());
|
|
60
|
+
inBlock = null;
|
|
61
|
+
} else collected.push(line);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (line === "" && collected.length === 0) continue; // skip leading blanks
|
|
65
|
+
if (line.startsWith("#!")) continue; // shebang
|
|
66
|
+
if (line.startsWith("//")) {
|
|
67
|
+
collected.push(line.replace(/^\/+/, "").trim());
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (line.startsWith("#")) {
|
|
71
|
+
collected.push(line.replace(/^#+/, "").trim());
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (line.startsWith("/*")) {
|
|
75
|
+
// Drop the opener, INCLUDING the `!` of a `/*!` "preserve" banner — else the
|
|
76
|
+
// stripped text is just "!", which the first-sentence regex then treats as a
|
|
77
|
+
// whole sentence, yielding the garbage summary "!".
|
|
78
|
+
collected.push(line.replace(/^\/\*+!?/, "").replace(/\*+\/\s*$/, "").trim());
|
|
79
|
+
if (!line.includes("*/")) inBlock = "c";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (line.startsWith('"""') || line.startsWith("'''")) {
|
|
83
|
+
const rest = line.slice(3);
|
|
84
|
+
if (rest.includes('"""') || rest.includes("'''")) collected.push(rest.replace(/['"]{3}.*$/, "").trim());
|
|
85
|
+
else {
|
|
86
|
+
collected.push(rest.trim());
|
|
87
|
+
inBlock = "py";
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
break; // first real code line
|
|
92
|
+
}
|
|
93
|
+
const text = collected
|
|
94
|
+
.filter((l) => l && !isDirective(l) && !isBanner(l))
|
|
95
|
+
.join(" ")
|
|
96
|
+
.replace(/\s+/g, " ")
|
|
97
|
+
.trim();
|
|
98
|
+
if (text.length < 8) return undefined;
|
|
99
|
+
// First sentence, capped.
|
|
100
|
+
const sentence = /^(.*?[.!?])(\s|$)/.exec(text);
|
|
101
|
+
return (sentence ? sentence[1]! : text).slice(0, 200);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Rust `use` paths may end in a brace group (`use crate::a::{b, c::d};`, nested
|
|
105
|
+
// allowed). Expand each leaf into a full path, capped — a giant prelude group
|
|
106
|
+
// shouldn't explode into hundreds of refs.
|
|
107
|
+
const MAX_USE_EXPANSION = 16;
|
|
108
|
+
function expandUseGroups(path: string, out: string[] = []): string[] {
|
|
109
|
+
if (out.length >= MAX_USE_EXPANSION) return out;
|
|
110
|
+
const brace = path.indexOf("{");
|
|
111
|
+
if (brace === -1) {
|
|
112
|
+
const cleaned = path.replace(/\s+as\s+\w+\s*$/, "").replace(/::\s*\*\s*$/, "").replace(/^::/, "").trim();
|
|
113
|
+
if (cleaned) out.push(cleaned);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
const prefix = path.slice(0, brace);
|
|
117
|
+
let depth = 0;
|
|
118
|
+
let end = -1;
|
|
119
|
+
for (let i = brace; i < path.length; i++) {
|
|
120
|
+
if (path[i] === "{") depth++;
|
|
121
|
+
else if (path[i] === "}" && --depth === 0) {
|
|
122
|
+
end = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (end === -1) return out; // unbalanced — drop rather than guess
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
let cur = "";
|
|
129
|
+
depth = 0;
|
|
130
|
+
for (const ch of path.slice(brace + 1, end)) {
|
|
131
|
+
if (ch === "{") depth++;
|
|
132
|
+
if (ch === "}") depth--;
|
|
133
|
+
if (ch === "," && depth === 0) {
|
|
134
|
+
parts.push(cur);
|
|
135
|
+
cur = "";
|
|
136
|
+
} else cur += ch;
|
|
137
|
+
}
|
|
138
|
+
parts.push(cur);
|
|
139
|
+
for (const part of parts) {
|
|
140
|
+
const t = part.trim();
|
|
141
|
+
if (!t) continue;
|
|
142
|
+
if (t === "self") expandUseGroups(prefix.replace(/::\s*$/, ""), out);
|
|
143
|
+
else expandUseGroups(prefix + t, out);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Extract import specifiers as written (no resolution). Resolution needs
|
|
149
|
+
// repo-wide context (tsconfig paths, go.mod, python roots) and happens later.
|
|
150
|
+
function extractImports(ext: string, content: string): RawRef[] {
|
|
151
|
+
const specs = new Set<string>();
|
|
152
|
+
const lines = content.split(/\r?\n/);
|
|
153
|
+
|
|
154
|
+
if (JS_TS.has(ext)) {
|
|
155
|
+
// Run over the WHOLE content, not line-by-line: a long `import { … } from "x"`
|
|
156
|
+
// (or `export { … } from "x"`) is routinely wrapped across several lines by
|
|
157
|
+
// formatters, and a per-line scan never sees the `from` clause — silently
|
|
158
|
+
// dropping the edge. `[^'"]*?` already excludes quotes, so it can't run past
|
|
159
|
+
// the statement's own specifier; the `g` flag also catches >1 per line.
|
|
160
|
+
let m: RegExpExecArray | null;
|
|
161
|
+
const from = /(?:^|[^\w$.])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
162
|
+
while ((m = from.exec(content))) specs.add(m[1]!);
|
|
163
|
+
const bare = /(?:^|[\n;])\s*import\s*['"]([^'"]+)['"]/g;
|
|
164
|
+
while ((m = bare.exec(content))) specs.add(m[1]!);
|
|
165
|
+
const req = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
166
|
+
while ((m = req.exec(content))) specs.add(m[1]!);
|
|
167
|
+
const dyn = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
168
|
+
while ((m = dyn.exec(content))) specs.add(m[1]!);
|
|
169
|
+
} else if (PY.has(ext)) {
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
const from = /^\s*from\s+(\.*[\w.]*)\s+import\b/.exec(line);
|
|
172
|
+
if (from) {
|
|
173
|
+
specs.add(from[1]!);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const imp = /^\s*import\s+(.+)$/.exec(line);
|
|
177
|
+
if (imp) {
|
|
178
|
+
for (const part of imp[1]!.split(",")) {
|
|
179
|
+
const name = part.trim().split(/\s+as\s+/)[0]!.trim();
|
|
180
|
+
if (name && /^[\w.]+$/.test(name)) specs.add(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else if (ext === ".go") {
|
|
185
|
+
let inBlock = false;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const t = line.trim();
|
|
188
|
+
if (inBlock) {
|
|
189
|
+
if (t === ")") {
|
|
190
|
+
inBlock = false;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const b = /"([^"]+)"/.exec(t);
|
|
194
|
+
if (b) specs.add(b[1]!);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (/^import\s*\($/.test(t)) {
|
|
198
|
+
inBlock = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const single = /^import\s+(?:[\w.]+\s+)?"([^"]+)"/.exec(t);
|
|
202
|
+
if (single) specs.add(single[1]!);
|
|
203
|
+
}
|
|
204
|
+
} else if (ext === ".rs") {
|
|
205
|
+
let m: RegExpExecArray | null;
|
|
206
|
+
// `mod foo;` declares a child module that MUST exist as a file (an inline
|
|
207
|
+
// `mod foo { … }` body has no `;` and is skipped).
|
|
208
|
+
const modRe = /^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;/gm;
|
|
209
|
+
while ((m = modRe.exec(content))) specs.add(`mod ${m[1]}`);
|
|
210
|
+
// `use` paths, brace groups expanded. External crates (std, serde, …) are
|
|
211
|
+
// filtered at resolve time, where the in-repo crate list lives.
|
|
212
|
+
const useRe = /^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gm;
|
|
213
|
+
while ((m = useRe.exec(content))) {
|
|
214
|
+
for (const p of expandUseGroups(m[1]!.trim())) specs.add(p);
|
|
215
|
+
}
|
|
216
|
+
} else if (ext === ".java") {
|
|
217
|
+
// `import com.a.b.C;` / `import static com.a.b.C.method;` — wildcards kept
|
|
218
|
+
// as written; the resolver maps packages onto source roots.
|
|
219
|
+
let m: RegExpExecArray | null;
|
|
220
|
+
const imp = /^\s*import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
|
|
221
|
+
while ((m = imp.exec(content))) specs.add(m[1]!);
|
|
222
|
+
} else if (ext === ".rb" || ext === ".rake") {
|
|
223
|
+
// `require_relative "x"` is relative to the file — emit it as a relative path
|
|
224
|
+
// (leading "./") so the resolver resolves it against the file's dir. `require
|
|
225
|
+
// "x"` is resolved against lib roots or is external (a gem).
|
|
226
|
+
let m: RegExpExecArray | null;
|
|
227
|
+
const rel = /^\s*require_relative\s+['"]([^'"]+)['"]/gm;
|
|
228
|
+
while ((m = rel.exec(content))) specs.add(/^\.\.?\//.test(m[1]!) ? m[1]! : "./" + m[1]!);
|
|
229
|
+
const req = /^\s*require\s+['"]([^'"]+)['"]/gm;
|
|
230
|
+
while ((m = req.exec(content))) specs.add(m[1]!);
|
|
231
|
+
} else if (C_CPP.has(ext)) {
|
|
232
|
+
// Local `#include "foo.h"` — a real in-repo dependency. `<...>` is a system/
|
|
233
|
+
// third-party header (external) and is deliberately not captured.
|
|
234
|
+
let m: RegExpExecArray | null;
|
|
235
|
+
const inc = /^\s*#\s*include\s*"([^"]+)"/gm;
|
|
236
|
+
while ((m = inc.exec(content))) specs.add(m[1]!);
|
|
237
|
+
} else if (ext === ".php") {
|
|
238
|
+
// `use Foo\Bar\Baz;` (namespace, resolved via composer PSR-4) and
|
|
239
|
+
// `require/include 'file.php'` (relative path, emitted with a leading "./").
|
|
240
|
+
let m: RegExpExecArray | null;
|
|
241
|
+
const use = /^\s*use\s+(?:function\s+|const\s+)?\\?([A-Za-z_][\w\\]*)\s*(?:as\s+\w+)?\s*;/gm;
|
|
242
|
+
while ((m = use.exec(content))) specs.add(m[1]!);
|
|
243
|
+
const inc = /\b(?:require|include)(?:_once)?\s*\(?\s*['"]([^'"]+)['"]/g;
|
|
244
|
+
while ((m = inc.exec(content))) specs.add(/^\.\.?\//.test(m[1]!) ? m[1]! : "./" + m[1]!);
|
|
245
|
+
} else if (ext === ".cs") {
|
|
246
|
+
// `using Foo.Bar;` — a namespace import, resolved to files declaring that
|
|
247
|
+
// namespace. Skip alias (`using X = ...`) and resource (`using (...)`) forms.
|
|
248
|
+
let m: RegExpExecArray | null;
|
|
249
|
+
const using = /^\s*(?:global\s+)?using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;/gm;
|
|
250
|
+
while ((m = using.exec(content))) specs.add(m[1]!);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return [...specs].map((spec) => ({ kind: "import" as const, spec }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Barrel re-exports (`export { A, B as C } from './x'`, `export * from './y'`).
|
|
257
|
+
// The line-based lang extractor can't capture multi-name lists, but these ARE
|
|
258
|
+
// the public facade of a module — so list them as exported symbols here.
|
|
259
|
+
function extractReexports(rel: string, content: string): CodeSymbol[] {
|
|
260
|
+
if (!JS_TS.has(rel.slice(rel.lastIndexOf(".")))) return [];
|
|
261
|
+
const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
|
|
262
|
+
const out: CodeSymbol[] = [];
|
|
263
|
+
const seen = new Set<string>();
|
|
264
|
+
const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
|
|
265
|
+
|
|
266
|
+
const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
|
|
267
|
+
let m: RegExpExecArray | null;
|
|
268
|
+
while ((m = named.exec(content)) && out.length < 60) {
|
|
269
|
+
const from = m[2];
|
|
270
|
+
for (const part of m[1]!.split(",")) {
|
|
271
|
+
const p = part.trim().replace(/^type\s+/, "");
|
|
272
|
+
const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
|
|
273
|
+
const name = as ? as[2]! : p;
|
|
274
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
|
|
275
|
+
seen.add(name);
|
|
276
|
+
out.push({
|
|
277
|
+
name, kind: "reexport", file: rel, line: lineAt(m.index),
|
|
278
|
+
signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
|
|
279
|
+
exported: true, lang,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
|
|
285
|
+
while ((m = star.exec(content)) && out.length < 60) {
|
|
286
|
+
const ns = m[1];
|
|
287
|
+
const from = m[2]!;
|
|
288
|
+
const key = "*" + (ns ?? from);
|
|
289
|
+
if (seen.has(key)) continue;
|
|
290
|
+
seen.add(key);
|
|
291
|
+
out.push({
|
|
292
|
+
name: ns ?? `* (${from})`, kind: ns ? "reexport" : "reexport-all", file: rel,
|
|
293
|
+
line: lineAt(m.index), signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
|
|
294
|
+
exported: true, lang,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Control-flow and declaration keywords that syntactically precede `(` but are
|
|
301
|
+
// never call targets — the union across supported languages. Deliberately does
|
|
302
|
+
// NOT list real builtins (python's `print`, go's `make`…): a false call to a
|
|
303
|
+
// name with no repo-wide def resolves to nothing downstream, while excluding a
|
|
304
|
+
// real function name would silently drop true edges.
|
|
305
|
+
const CALL_KEYWORDS = new Set([
|
|
306
|
+
"if", "else", "elif", "for", "while", "do", "switch", "case", "match", "when", "unless", "until",
|
|
307
|
+
"catch", "except", "return", "throw", "raise", "yield", "await", "typeof", "instanceof", "sizeof",
|
|
308
|
+
"delete", "void", "in", "of", "not", "and", "or", "assert", "defer", "select", "with", "loop",
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
// Introducers whose FOLLOWING identifier is a definition, not a call:
|
|
312
|
+
// `function foo(`, `def foo(`, `func foo(`, `fn foo(`, `class Foo(`, `sub foo(`.
|
|
313
|
+
const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bmacro|\bproc)\s*[*]?\s*$/;
|
|
314
|
+
|
|
315
|
+
// Regex-tier call-site collection for files with no AST grammar — a
|
|
316
|
+
// conservative `identifier(` scan so call data exists wasm-free (the AST tier
|
|
317
|
+
// stays authoritative when available). Same contract as ast/extract's
|
|
318
|
+
// collector: cap 512, deduped by name+line, sorted by name then line. An
|
|
319
|
+
// immediate `receiver.` prefix is captured too (`axios.get(` → receiver
|
|
320
|
+
// "axios"; `a.b.c(` → receiver "b" — the group anchors to the segment right
|
|
321
|
+
// before the called name); bare calls carry no receiver.
|
|
322
|
+
function collectCallsRegex(content: string): { name: string; line: number; receiver?: string }[] {
|
|
323
|
+
const out = new Map<string, { name: string; line: number; receiver?: string }>();
|
|
324
|
+
const lines = content.split("\n");
|
|
325
|
+
const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
|
|
326
|
+
for (let i = 0; i < lines.length && out.size < 512; i++) {
|
|
327
|
+
const line = lines[i]!;
|
|
328
|
+
// Cheap comment guard: a line-leading comment marker means no calls here
|
|
329
|
+
// (block-comment interiors and strings stay best-effort, like the symbol
|
|
330
|
+
// regexes — noise resolves to nothing in the global call pass).
|
|
331
|
+
const trimmed = line.trimStart();
|
|
332
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) continue;
|
|
333
|
+
CALL_RE.lastIndex = 0;
|
|
334
|
+
let m: RegExpExecArray | null;
|
|
335
|
+
while ((m = CALL_RE.exec(line)) !== null && out.size < 512) {
|
|
336
|
+
const receiver = m[1];
|
|
337
|
+
const name = m[2]!;
|
|
338
|
+
if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
|
|
339
|
+
if (DEF_INTRODUCERS.test(line.slice(0, m.index))) continue;
|
|
340
|
+
const key = `${name} ${i + 1}`;
|
|
341
|
+
if (!out.has(key)) out.set(key, receiver ? { name, line: i + 1, receiver } : { name, line: i + 1 });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return [...out.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.line - b.line));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function extractCode(rel: string, ext: string, content: string): CodeInfo {
|
|
348
|
+
// Symbols come from tree-sitter when a grammar is loaded for this extension
|
|
349
|
+
// (AST-exact: real nesting, precise kinds, structural export), else the regex
|
|
350
|
+
// extractors. Imports/pkg stay on the battle-tested regex path here — their
|
|
351
|
+
// resolution is covered by resolve tests and the e2e ratchet; the new-language
|
|
352
|
+
// AST importers land with their resolvers.
|
|
353
|
+
const ast = extractAst(rel, ext, content);
|
|
354
|
+
const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
|
|
355
|
+
// Add barrel re-exports the local def didn't already cover.
|
|
356
|
+
const known = new Set(symbols.map((s) => s.name));
|
|
357
|
+
const reexports = extractReexports(rel, content).filter((s) => !known.has(s.name));
|
|
358
|
+
return {
|
|
359
|
+
symbols: [...symbols, ...reexports],
|
|
360
|
+
summary: topDocComment(content),
|
|
361
|
+
refs: extractImports(ext, content),
|
|
362
|
+
// pkg anchors namespace→source-root resolution: Java's `package`, C#'s
|
|
363
|
+
// `namespace` (block or file-scoped). Both feed the same resolver pattern.
|
|
364
|
+
pkg:
|
|
365
|
+
ext === ".java"
|
|
366
|
+
? /^\s*package\s+([\w.]+)\s*;/m.exec(content)?.[1]
|
|
367
|
+
: ext === ".cs"
|
|
368
|
+
? /^\s*(?:file-scoped\s+)?namespace\s+([\w.]+)/m.exec(content)?.[1]
|
|
369
|
+
: undefined,
|
|
370
|
+
idents: ast?.idents,
|
|
371
|
+
// AST call sites when a grammar parsed the file; the conservative regex
|
|
372
|
+
// collector otherwise, so caller indexes exist without the wasm sidecar.
|
|
373
|
+
calls: ast ? ast.calls : collectCallsRegex(content),
|
|
374
|
+
importedNames: ast?.importedNames,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { RawRef } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export interface MarkdownInfo {
|
|
4
|
+
title?: string;
|
|
5
|
+
summary?: string;
|
|
6
|
+
headings: string[];
|
|
7
|
+
refs: RawRef[]; // doc-link refs (local relative targets only)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Strip fenced code blocks (``` … ``` and ~~~ … ~~~) so links/headings inside
|
|
11
|
+
// them are not mistaken for real content. Replaces them with blank lines to
|
|
12
|
+
// preserve line-based scanning elsewhere.
|
|
13
|
+
function stripFences(content: string): string {
|
|
14
|
+
const lines = content.split(/\r?\n/);
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
let fence: string | null = null;
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const m = /^\s*(```+|~~~+)/.exec(line);
|
|
19
|
+
if (fence) {
|
|
20
|
+
if (m && line.trim().startsWith(fence[0]![0]!.repeat(3).slice(0, 3))) fence = null;
|
|
21
|
+
out.push("");
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (m) {
|
|
25
|
+
fence = m[1]!;
|
|
26
|
+
out.push("");
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
out.push(line);
|
|
30
|
+
}
|
|
31
|
+
return out.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A link target is "external" (not a graph edge candidate) when it is a URL, a
|
|
35
|
+
// mail/other scheme, protocol-relative, or a pure in-page anchor.
|
|
36
|
+
function isExternalTarget(spec: string): boolean {
|
|
37
|
+
if (!spec) return true;
|
|
38
|
+
if (spec.startsWith("#")) return true;
|
|
39
|
+
if (spec.startsWith("//")) return true;
|
|
40
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(spec); // http:, https:, mailto:, tel:, data:, …
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One line of human-meaningful prose for the summary: drop images/badges
|
|
44
|
+
// ENTIRELY (so a badge-only line yields nothing, not its alt text), keep link
|
|
45
|
+
// text, strip emphasis, collapse whitespace.
|
|
46
|
+
function cleanProse(line: string): string {
|
|
47
|
+
return line
|
|
48
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images/badges → removed, not kept as alt
|
|
49
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
50
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
51
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
52
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
53
|
+
.replace(/[#>*_~-]+/g, " ")
|
|
54
|
+
.replace(/\s+/g, " ")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Does a cleaned line carry actual prose (a word with letters), or is it just
|
|
59
|
+
// punctuation/leftovers from a badge row?
|
|
60
|
+
function hasProse(s: string): boolean {
|
|
61
|
+
return /[A-Za-zÀ-ɏ]{3,}/.test(s);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Generic doc-template boilerplate that says nothing about THIS project.
|
|
65
|
+
function isBoilerplate(s: string): boolean {
|
|
66
|
+
return /^(all notable changes to this project|in the interest of fostering|this project adheres to|we as members and leaders|table of contents)\b/i.test(s);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Extract title, section headings, a one-line summary, and local doc-link refs
|
|
70
|
+
// from a markdown document. Deterministic and dependency-free.
|
|
71
|
+
export function extractMarkdown(content: string): MarkdownInfo {
|
|
72
|
+
let body = content;
|
|
73
|
+
let frontTitle: string | undefined;
|
|
74
|
+
|
|
75
|
+
// Strip a leading YAML frontmatter block, capturing a `title:` if present.
|
|
76
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(body);
|
|
77
|
+
if (fm) {
|
|
78
|
+
const t = /(^|\n)title:\s*["']?(.+?)["']?\s*(\n|$)/i.exec(fm[1]!);
|
|
79
|
+
if (t) frontTitle = t[2]!.trim();
|
|
80
|
+
body = body.slice(fm[0].length);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const scan = stripFences(body);
|
|
84
|
+
const lines = scan.split(/\r?\n/);
|
|
85
|
+
|
|
86
|
+
const headings: string[] = [];
|
|
87
|
+
let title: string | undefined = frontTitle;
|
|
88
|
+
let summary: string | undefined;
|
|
89
|
+
// The summary must come from the document's own intro — once a level-2+
|
|
90
|
+
// section starts, a later paragraph belongs to that sub-section, not the doc.
|
|
91
|
+
let summaryClosed = false;
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const h = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
94
|
+
if (h) {
|
|
95
|
+
const text = cleanProse(h[2]!);
|
|
96
|
+
headings.push(text);
|
|
97
|
+
if (!title && h[1]!.length === 1) title = text;
|
|
98
|
+
if (!summary && h[1]!.length >= 2) summaryClosed = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!summary && !summaryClosed) {
|
|
102
|
+
const t = line.trim();
|
|
103
|
+
// First real prose paragraph: not a heading, list bullet, table, html or blank.
|
|
104
|
+
if (t && !/^([-*+]|\d+\.)\s/.test(t) && !t.startsWith("|") && !t.startsWith("<")) {
|
|
105
|
+
const cleaned = cleanProse(t);
|
|
106
|
+
// Reject list lead-ins ("Exemple :", "Nous avons :") and known boilerplate.
|
|
107
|
+
if (cleaned.length >= 8 && hasProse(cleaned) && !cleaned.endsWith(":") && !isBoilerplate(cleaned)) {
|
|
108
|
+
summary = cleaned.slice(0, 200);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Local doc-link refs: inline `[t](target)` / `` plus
|
|
115
|
+
// reference-style definitions `[id]: target`. External/anchor targets dropped.
|
|
116
|
+
const refs: RawRef[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
const addRef = (raw: string) => {
|
|
119
|
+
let spec = raw.trim();
|
|
120
|
+
// Strip an optional `"title"` after the URL in `](url "title")`.
|
|
121
|
+
spec = spec.replace(/\s+["'(].*$/, "").trim();
|
|
122
|
+
spec = spec.replace(/^<|>$/g, "");
|
|
123
|
+
if (isExternalTarget(spec)) return;
|
|
124
|
+
if (seen.has(spec)) return;
|
|
125
|
+
seen.add(spec);
|
|
126
|
+
refs.push({ kind: "doc-link", spec });
|
|
127
|
+
};
|
|
128
|
+
const inline = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
129
|
+
let m: RegExpExecArray | null;
|
|
130
|
+
while ((m = inline.exec(scan))) addRef(m[1]!);
|
|
131
|
+
const refdef = /^\s*\[[^\]]+\]:\s+(\S+)/gm;
|
|
132
|
+
while ((m = refdef.exec(scan))) addRef(m[1]!);
|
|
133
|
+
|
|
134
|
+
return { title, summary, headings, refs };
|
|
135
|
+
}
|