@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.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. 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)` / `![a](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
+ }