pi-fovea 0.28.2 → 0.29.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 +8 -1
- package/dist/cli.mjs +180 -42
- package/package.json +1 -1
- package/src/core/astgrep.ts +1 -0
- package/src/core/bend.ts +127 -0
- package/src/core/build.ts +5 -4
- package/src/core/extract.ts +24 -11
- package/src/core/graph.ts +18 -3
- package/src/core/state.ts +14 -3
- package/src/core/sync.ts +1 -1
- package/src/core/types.ts +2 -0
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Runtime slash controls:
|
|
|
108
108
|
|
|
109
109
|
## Install
|
|
110
110
|
|
|
111
|
-
Requires Node.js 20+. The install provisions [ast-grep](https://ast-grep.github.io/) automatically through the `@ast-grep/cli` npm optional dependency; an `ast-grep` found on PATH takes precedence over the packaged copy, and `FOVEA_AST_GREP=/path/to/sg` overrides both.
|
|
111
|
+
Requires Node.js 20+. The install provisions [ast-grep](https://ast-grep.github.io/) automatically through the `@ast-grep/cli` npm optional dependency; an `ast-grep` found on PATH takes precedence over the packaged copy, and `FOVEA_AST_GREP=/path/to/sg` overrides both. Bend-only and config/protocol-only roots work without ast-grep; it is required when discovery or refresh includes a language that uses its parser.
|
|
112
112
|
|
|
113
113
|
```sh
|
|
114
114
|
pi install npm:pi-fovea
|
|
@@ -529,6 +529,13 @@ Outline-based symbols: **Elixir, Ruby, C, C++, Java, Kotlin, Lua, PHP, Swift, Sc
|
|
|
529
529
|
Config joins through literals: **YAML, JSON, TOML, env, Markdown, and OpenAPI**.
|
|
530
530
|
Exact contract topology: **Protocol Buffers (`.proto`) and GraphQL (`.graphql`, `.gql`)**, joined to gRPC, tRPC, oRPC, Hono, and producer/consumer call sites.
|
|
531
531
|
|
|
532
|
+
**Bend 2 (`.bend`)** uses a native source reader (no ast-grep binary or grammar required):
|
|
533
|
+
definitions, laws, datatypes/constructors, explicit calls (including GPU `!`),
|
|
534
|
+
strings, and local/foreign imports. Module aliases resolve qualified calls.
|
|
535
|
+
A law implemented in the same file shares its definition's symbol. Built-in
|
|
536
|
+
`Base`, hub packages, and out-of-root imports remain unresolved. Operator/desugared
|
|
537
|
+
calls and proof checking are not modeled; Bend 1/HVM syntax is not supported.
|
|
538
|
+
|
|
532
539
|
## Development
|
|
533
540
|
|
|
534
541
|
```sh
|
package/dist/cli.mjs
CHANGED
|
@@ -1235,6 +1235,8 @@ var LANG_BY_EXT = {
|
|
|
1235
1235
|
py: "Python",
|
|
1236
1236
|
go: "Go",
|
|
1237
1237
|
rs: "Rust",
|
|
1238
|
+
bend: "Bend",
|
|
1239
|
+
// Native source reader; never sent to ast-grep patterns.
|
|
1238
1240
|
// Second tier: symbols via ast-grep outline; name derivation is heuristic.
|
|
1239
1241
|
ex: "Elixir",
|
|
1240
1242
|
exs: "Elixir",
|
|
@@ -1592,6 +1594,115 @@ var patternRunAll = async (patterns, lang, files, cwd) => {
|
|
|
1592
1594
|
return out;
|
|
1593
1595
|
};
|
|
1594
1596
|
|
|
1597
|
+
// src/core/bend.ts
|
|
1598
|
+
var NAME = "[A-Za-z_][A-Za-z0-9_.]*";
|
|
1599
|
+
var DECL = new RegExp(`^(?:@unsafe\\s+)?(def|law|type)\\s+(${NAME})(?=[\\s(<:]|$)`);
|
|
1600
|
+
var CONSTRUCTOR = new RegExp(`^\\s+(${NAME})\\s*\\{`);
|
|
1601
|
+
var CALL = new RegExp(`\\b(${NAME})\\s*!?\\s*\\(`, "g");
|
|
1602
|
+
var CALL_KEYWORDS = /* @__PURE__ */ new Set(["def", "type", "law", "match", "case", "do", "return", "for", "exs", "where", "is", "import", "Type", "Data", "Kind", "Quant"]);
|
|
1603
|
+
var IMPORT = new RegExp(`^import\\s+(\\S+?)(?:\\s+as\\s+(${NAME}))?\\s*$`);
|
|
1604
|
+
var extractBend = (file, text) => {
|
|
1605
|
+
const facts = { symbols: [], imports: [], calls: [], literals: [] };
|
|
1606
|
+
let inType = false;
|
|
1607
|
+
let header = false;
|
|
1608
|
+
let brackets = [];
|
|
1609
|
+
let quote = "";
|
|
1610
|
+
let value = "";
|
|
1611
|
+
let literalLine = 0;
|
|
1612
|
+
const lines = text.split("\n");
|
|
1613
|
+
for (let at = 0; at < lines.length; at++) {
|
|
1614
|
+
const raw = lines[at];
|
|
1615
|
+
const line = at + 1;
|
|
1616
|
+
let code = "";
|
|
1617
|
+
let visible = "";
|
|
1618
|
+
const strings = [];
|
|
1619
|
+
let start = -1;
|
|
1620
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1621
|
+
const ch = raw[i];
|
|
1622
|
+
if (quote) {
|
|
1623
|
+
code += " ";
|
|
1624
|
+
visible += ch;
|
|
1625
|
+
if (ch === "\\" && i + 1 < raw.length) {
|
|
1626
|
+
const escaped = raw[++i];
|
|
1627
|
+
value += { n: "\n", r: "\r", t: " ", "0": "\0" }[escaped] ?? escaped;
|
|
1628
|
+
code += " ";
|
|
1629
|
+
visible += escaped;
|
|
1630
|
+
} else if (ch === quote) {
|
|
1631
|
+
if (quote === '"') {
|
|
1632
|
+
strings.push({ start, text: value });
|
|
1633
|
+
if (value.length >= 2 && value.length <= 200) facts.literals.push({ file, line: literalLine, text: value });
|
|
1634
|
+
}
|
|
1635
|
+
quote = "";
|
|
1636
|
+
} else value += ch;
|
|
1637
|
+
} else if (ch === "#") {
|
|
1638
|
+
break;
|
|
1639
|
+
} else if (ch === '"' || ch === "'") {
|
|
1640
|
+
quote = ch;
|
|
1641
|
+
value = "";
|
|
1642
|
+
literalLine = line;
|
|
1643
|
+
start = i;
|
|
1644
|
+
code += " ";
|
|
1645
|
+
visible += ch;
|
|
1646
|
+
} else {
|
|
1647
|
+
code += ch;
|
|
1648
|
+
visible += ch;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (quote) value += "\n";
|
|
1652
|
+
if (!code.trim()) continue;
|
|
1653
|
+
const decl = DECL.exec(code);
|
|
1654
|
+
if (decl) {
|
|
1655
|
+
const kind = decl[1] === "def" ? "function" : decl[1] === "type" ? "type" : "decl";
|
|
1656
|
+
const sig = visible.trim();
|
|
1657
|
+
facts.symbols.push({ file, line, name: decl[2], kind, sig: sig.length > 140 ? sig.slice(0, 137) + "..." : sig, lang: "Bend" });
|
|
1658
|
+
inType = decl[1] === "type";
|
|
1659
|
+
header = true;
|
|
1660
|
+
brackets = [];
|
|
1661
|
+
} else if (!header && /^\S/.test(code)) {
|
|
1662
|
+
inType = false;
|
|
1663
|
+
}
|
|
1664
|
+
if (header) {
|
|
1665
|
+
for (let i = 0; i < code.length; i++) {
|
|
1666
|
+
const ch = code[i];
|
|
1667
|
+
if ("(<{[".includes(ch)) brackets.push(ch);
|
|
1668
|
+
else if (")>}]".includes(ch) && !(ch === ">" && code[i - 1] === "-")) {
|
|
1669
|
+
if (brackets.at(-1) === { ")": "(", ">": "<", "}": "{", "]": "[" }[ch]) brackets.pop();
|
|
1670
|
+
} else if (ch === ":" && brackets.length === 0) {
|
|
1671
|
+
header = false;
|
|
1672
|
+
code = " ".repeat(i + 1) + code.slice(i + 1);
|
|
1673
|
+
break;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (header) continue;
|
|
1677
|
+
}
|
|
1678
|
+
const imp = IMPORT.exec(code);
|
|
1679
|
+
if (imp) {
|
|
1680
|
+
facts.imports.push({ file, line, spec: imp[1], ...imp[2] ? { alias: imp[2] } : {} });
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
if (/^\s+import\s+/.test(code)) {
|
|
1684
|
+
const path = strings.find((s) => s.start >= 0 && /^\s+import\s+$/.test(code.slice(0, s.start)));
|
|
1685
|
+
if (path) facts.imports.push({ file, line, spec: path.text });
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
if (inType) {
|
|
1689
|
+
const ctor = CONSTRUCTOR.exec(code);
|
|
1690
|
+
if (ctor) facts.symbols.push({ file, line, name: ctor[1], kind: "decl", sig: visible.trim().slice(0, 140), lang: "Bend" });
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
CALL.lastIndex = 0;
|
|
1694
|
+
for (const call of code.matchAll(CALL)) {
|
|
1695
|
+
if (!CALL_KEYWORDS.has(call[1])) facts.calls.push({ file, line, callee: call[1] });
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
const symbols = /* @__PURE__ */ new Map();
|
|
1699
|
+
for (const symbol of facts.symbols) {
|
|
1700
|
+
if (!symbols.has(symbol.name) || symbol.kind === "function") symbols.set(symbol.name, symbol);
|
|
1701
|
+
}
|
|
1702
|
+
facts.symbols = [...symbols.values()].sort((a, b) => a.line - b.line);
|
|
1703
|
+
return facts;
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1595
1706
|
// src/core/source.ts
|
|
1596
1707
|
import { readFile } from "node:fs/promises";
|
|
1597
1708
|
import { join as join4 } from "node:path";
|
|
@@ -1841,9 +1952,14 @@ var parseOutlineText = (text, lang) => {
|
|
|
1841
1952
|
return out;
|
|
1842
1953
|
};
|
|
1843
1954
|
var defaultSource = (cwd) => makeFileSource(cwd);
|
|
1955
|
+
var bendFacts = async (files, source) => mapLimit(files.filter((file) => langOf(file) === "Bend"), SOURCE_SCAN_CONCURRENCY, async (file) => extractBend(file, await source.read(file) ?? ""));
|
|
1844
1956
|
var extractSymbols = async (files, cwd, source = defaultSource(cwd)) => {
|
|
1845
1957
|
const out = [];
|
|
1846
1958
|
for (const [lang, langFiles] of groupByLang(files)) {
|
|
1959
|
+
if (lang === "Bend") {
|
|
1960
|
+
for (const facts of await bendFacts(langFiles, source)) pushAll(out, facts.symbols);
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1847
1963
|
const structured = await outlineStructured(langFiles, lang, cwd);
|
|
1848
1964
|
if (structured) {
|
|
1849
1965
|
const parsed = await parseStructuredOutline(structured, source);
|
|
@@ -1879,7 +1995,7 @@ var IMPORT_PATTERNS = {
|
|
|
1879
1995
|
};
|
|
1880
1996
|
IMPORT_PATTERNS.JavaScript = IMPORT_PATTERNS.TypeScript;
|
|
1881
1997
|
IMPORT_PATTERNS.Tsx = IMPORT_PATTERNS.TypeScript;
|
|
1882
|
-
var supportsImportExtraction = (language) => !!IMPORT_PATTERNS[language]?.length;
|
|
1998
|
+
var supportsImportExtraction = (language) => language === "Bend" || !!IMPORT_PATTERNS[language]?.length;
|
|
1883
1999
|
var importExpression = (raw) => {
|
|
1884
2000
|
const expression = raw.trim();
|
|
1885
2001
|
const literal = (text) => {
|
|
@@ -1928,13 +2044,13 @@ var importsFromMatches = (matches) => {
|
|
|
1928
2044
|
}
|
|
1929
2045
|
return dedupe(out, (i) => `${i.file}|${i.spec}|${i.line}`);
|
|
1930
2046
|
};
|
|
1931
|
-
var extractImports = async (files, cwd) => {
|
|
2047
|
+
var extractImports = async (files, cwd, source = defaultSource(cwd)) => {
|
|
1932
2048
|
const perLang = await Promise.all([...groupByLang(files)].map(
|
|
1933
2049
|
([lang, langFiles]) => patternRunAll(IMPORT_PATTERNS[lang] ?? [], lang, langFiles, cwd)
|
|
1934
2050
|
));
|
|
1935
2051
|
const matches = [];
|
|
1936
2052
|
for (const local of perLang) pushAll(matches, local);
|
|
1937
|
-
return importsFromMatches(matches);
|
|
2053
|
+
return [...importsFromMatches(matches), ...(await bendFacts(files, source)).flatMap((facts) => facts.imports)];
|
|
1938
2054
|
};
|
|
1939
2055
|
var CALL_PATTERNS = ["$O.$M($$$A)", "$F($$$A)"];
|
|
1940
2056
|
var CALL_WARDS = /* @__PURE__ */ new Set([
|
|
@@ -2081,13 +2197,13 @@ var callsFromMatches = (matches) => {
|
|
|
2081
2197
|
}
|
|
2082
2198
|
return out;
|
|
2083
2199
|
};
|
|
2084
|
-
var extractCalls = async (files, cwd) => {
|
|
2085
|
-
const perLang = await Promise.all([...groupByLang(files)].map(
|
|
2200
|
+
var extractCalls = async (files, cwd, source = defaultSource(cwd)) => {
|
|
2201
|
+
const perLang = await Promise.all([...groupByLang(files)].filter(([lang]) => lang !== "Bend").map(
|
|
2086
2202
|
([lang, langFiles]) => patternRunAll(CALL_PATTERNS, lang, langFiles, cwd)
|
|
2087
2203
|
));
|
|
2088
2204
|
const matches = [];
|
|
2089
2205
|
for (const local of perLang) pushAll(matches, local);
|
|
2090
|
-
return callsFromMatches(matches);
|
|
2206
|
+
return [...callsFromMatches(matches), ...(await bendFacts(files, source)).flatMap((facts) => facts.calls)];
|
|
2091
2207
|
};
|
|
2092
2208
|
var STRING_PATTERNS = {
|
|
2093
2209
|
TypeScript: ['"$S"', "'$S'", "`$S`"],
|
|
@@ -2155,7 +2271,7 @@ var literalsFromMatches = (matches) => {
|
|
|
2155
2271
|
};
|
|
2156
2272
|
var completeLiterals = async (files, cwd, source, out) => {
|
|
2157
2273
|
pushAll(out, await extractConfigLiterals(files, cwd, source));
|
|
2158
|
-
const codeFiles = files.filter((f) => !isConfigFile(f));
|
|
2274
|
+
const codeFiles = files.filter((f) => !isConfigFile(f) && langOf(f) !== "Bend");
|
|
2159
2275
|
const templateSites = await mapLimit(codeFiles, SOURCE_SCAN_CONCURRENCY, async (f) => {
|
|
2160
2276
|
const local = [];
|
|
2161
2277
|
const src = await source.read(f);
|
|
@@ -2178,7 +2294,8 @@ var extractLiterals = async (files, cwd, source = defaultSource(cwd)) => {
|
|
|
2178
2294
|
));
|
|
2179
2295
|
const matches = [];
|
|
2180
2296
|
for (const local of perLang) pushAll(matches, local);
|
|
2181
|
-
|
|
2297
|
+
const native = (await bendFacts(files, source)).flatMap((facts) => facts.literals);
|
|
2298
|
+
return completeLiterals(files, cwd, source, [...literalsFromMatches(matches), ...native]);
|
|
2182
2299
|
};
|
|
2183
2300
|
var CORE_IMPORT_PREFIX = "fovea-core-import-";
|
|
2184
2301
|
var CORE_CALL_PREFIX = "fovea-core-call-";
|
|
@@ -2195,6 +2312,7 @@ var coreScanRules = (files) => {
|
|
|
2195
2312
|
});
|
|
2196
2313
|
};
|
|
2197
2314
|
for (const [language] of groupByLang(files)) {
|
|
2315
|
+
if (language === "Bend") continue;
|
|
2198
2316
|
for (const pattern of IMPORT_PATTERNS[language] ?? []) add(CORE_IMPORT_PREFIX, language, pattern);
|
|
2199
2317
|
for (const pattern of CALL_PATTERNS) {
|
|
2200
2318
|
const metavar = pattern.startsWith("$O.") ? "M" : "F";
|
|
@@ -2213,10 +2331,11 @@ var coreFactsFromScan = async (files, cwd, source, matches) => {
|
|
|
2213
2331
|
else if (match.ruleId.startsWith(CORE_CALL_PREFIX)) calls.push(match);
|
|
2214
2332
|
else if (match.ruleId.startsWith(CORE_LITERAL_PREFIX)) literals.push(match);
|
|
2215
2333
|
}
|
|
2334
|
+
const native = await bendFacts(files, source);
|
|
2216
2335
|
return {
|
|
2217
|
-
imports: importsFromMatches(imports),
|
|
2218
|
-
calls: callsFromMatches(calls),
|
|
2219
|
-
literals: await completeLiterals(files, cwd, source, literalsFromMatches(literals))
|
|
2336
|
+
imports: [...importsFromMatches(imports), ...native.flatMap((facts) => facts.imports)],
|
|
2337
|
+
calls: [...callsFromMatches(calls), ...native.flatMap((facts) => facts.calls)],
|
|
2338
|
+
literals: await completeLiterals(files, cwd, source, [...literalsFromMatches(literals), ...native.flatMap((facts) => facts.literals)])
|
|
2220
2339
|
};
|
|
2221
2340
|
};
|
|
2222
2341
|
var pushAll = (out, more) => {
|
|
@@ -2599,6 +2718,12 @@ var resolveImportToFile = (spec, fromFile, index) => {
|
|
|
2599
2718
|
const hits2 = [...new Set(candidates)].filter((candidate) => fileSet.has(candidate));
|
|
2600
2719
|
return hits2.length ? { file: hits2[0], evidence: { strategy, rule: "import-resolve", source: spec, candidates: hits2.length } } : void 0;
|
|
2601
2720
|
};
|
|
2721
|
+
if (fam === "bend") {
|
|
2722
|
+
if (spec === "Base" || /^0x[0-9a-f]+\//i.test(spec) || posix2.isAbsolute(spec)) return void 0;
|
|
2723
|
+
const local = posix2.normalize(posix2.join(posix2.dirname(fromFile), spec));
|
|
2724
|
+
if (local === ".." || local.startsWith("../")) return void 0;
|
|
2725
|
+
return exact([local], "relative-import");
|
|
2726
|
+
}
|
|
2602
2727
|
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
2603
2728
|
let base = posix2.normalize(posix2.join(posix2.dirname(fromFile), spec));
|
|
2604
2729
|
base = base.replace(/\.(?:[cm]?js|jsx)$/, "");
|
|
@@ -2815,7 +2940,7 @@ var assembleGraphWithIndex = async (root, files, factsMap) => {
|
|
|
2815
2940
|
importCoverage.resolved++;
|
|
2816
2941
|
if (target.file === rel) continue;
|
|
2817
2942
|
pushEdge(fileIdx.get(rel), fileIdx.get(target.file), "imports", 0.3, target.evidence);
|
|
2818
|
-
(importTargets.get(rel) ?? importTargets.set(rel, []).get(rel)).push(target);
|
|
2943
|
+
(importTargets.get(rel) ?? importTargets.set(rel, []).get(rel)).push({ ...target, alias: imp.alias });
|
|
2819
2944
|
}
|
|
2820
2945
|
if (isTestFile(rel)) {
|
|
2821
2946
|
for (const target of importTargets.get(rel) ?? []) {
|
|
@@ -2831,9 +2956,14 @@ var assembleGraphWithIndex = async (root, files, factsMap) => {
|
|
|
2831
2956
|
await forEachChunked(files, 256, (rel) => {
|
|
2832
2957
|
const f = facts(rel);
|
|
2833
2958
|
if (!f) return;
|
|
2834
|
-
const
|
|
2959
|
+
const targets = importTargets.get(rel) ?? [];
|
|
2960
|
+
const imported = new Set(targets.map((target) => target.file));
|
|
2961
|
+
const bend = langFamily(rel) === "bend";
|
|
2962
|
+
const aliases = bend ? targets.filter((target) => target.alias).sort((a, b) => b.alias.length - a.alias.length) : [];
|
|
2835
2963
|
for (const call of f.calls) {
|
|
2836
|
-
const
|
|
2964
|
+
const alias = aliases.find((target) => call.callee.startsWith(`${target.alias}.`));
|
|
2965
|
+
const name = alias ? call.callee.slice(alias.alias.length + 1) : call.callee;
|
|
2966
|
+
const cands = (byName.get(name.toLowerCase()) ?? []).filter((i) => !bend || nodes[i].lang === "Bend" && nodes[i].name === name && nodes[i].file === (alias?.file ?? rel));
|
|
2837
2967
|
if (!cands.length || cands.length > 48) continue;
|
|
2838
2968
|
let strategy = "same-file-symbol";
|
|
2839
2969
|
let chosen = cands.filter((i) => nodes[i].file === rel);
|
|
@@ -3714,7 +3844,7 @@ var promote = (sigs, pack = []) => {
|
|
|
3714
3844
|
// src/core/protocols.ts
|
|
3715
3845
|
import { extname } from "node:path";
|
|
3716
3846
|
var PROTOCOL_EXTENSIONS = /* @__PURE__ */ new Set([".proto", ".graphql", ".gql"]);
|
|
3717
|
-
var
|
|
3847
|
+
var NAME2 = /[_A-Za-z][_0-9A-Za-z]*/y;
|
|
3718
3848
|
var maskSyntax = (text, hashComments) => {
|
|
3719
3849
|
const chars = text.split("");
|
|
3720
3850
|
const blank = (index) => {
|
|
@@ -3921,15 +4051,15 @@ var protoAnchors = (file, source) => {
|
|
|
3921
4051
|
var graphqlFields = (text, start, end) => {
|
|
3922
4052
|
const out = [];
|
|
3923
4053
|
for (let i = start; i < end; ) {
|
|
3924
|
-
|
|
3925
|
-
const match =
|
|
4054
|
+
NAME2.lastIndex = i;
|
|
4055
|
+
const match = NAME2.exec(text);
|
|
3926
4056
|
if (!match) {
|
|
3927
4057
|
i++;
|
|
3928
4058
|
continue;
|
|
3929
4059
|
}
|
|
3930
4060
|
const name = match[0];
|
|
3931
|
-
let cursor =
|
|
3932
|
-
let resume =
|
|
4061
|
+
let cursor = NAME2.lastIndex;
|
|
4062
|
+
let resume = NAME2.lastIndex;
|
|
3933
4063
|
while (cursor < end && /\s/.test(text[cursor])) cursor++;
|
|
3934
4064
|
if (text[cursor] === "(") {
|
|
3935
4065
|
let depth = 0;
|
|
@@ -3956,21 +4086,21 @@ var firstOperationField = (text, start, end) => {
|
|
|
3956
4086
|
if (text.startsWith("...", i)) {
|
|
3957
4087
|
i += 3;
|
|
3958
4088
|
while (i < end && /\s/.test(text[i])) i++;
|
|
3959
|
-
|
|
3960
|
-
const spread =
|
|
3961
|
-
i = spread ?
|
|
4089
|
+
NAME2.lastIndex = i;
|
|
4090
|
+
const spread = NAME2.exec(text);
|
|
4091
|
+
i = spread ? NAME2.lastIndex : i + 1;
|
|
3962
4092
|
if (spread?.[0] !== "on") continue;
|
|
3963
4093
|
while (i < end && /\s/.test(text[i])) i++;
|
|
3964
|
-
|
|
3965
|
-
const typeCondition =
|
|
3966
|
-
i = typeCondition ?
|
|
4094
|
+
NAME2.lastIndex = i;
|
|
4095
|
+
const typeCondition = NAME2.exec(text);
|
|
4096
|
+
i = typeCondition ? NAME2.lastIndex : i;
|
|
3967
4097
|
while (i < end) {
|
|
3968
4098
|
while (i < end && /\s/.test(text[i])) i++;
|
|
3969
4099
|
if (text[i] !== "@") break;
|
|
3970
4100
|
i++;
|
|
3971
|
-
|
|
3972
|
-
const directive =
|
|
3973
|
-
i = directive ?
|
|
4101
|
+
NAME2.lastIndex = i;
|
|
4102
|
+
const directive = NAME2.exec(text);
|
|
4103
|
+
i = directive ? NAME2.lastIndex : i + 1;
|
|
3974
4104
|
while (i < end && /\s/.test(text[i])) i++;
|
|
3975
4105
|
if (text[i] === "(") {
|
|
3976
4106
|
let depth = 0;
|
|
@@ -3993,9 +4123,9 @@ var firstOperationField = (text, start, end) => {
|
|
|
3993
4123
|
}
|
|
3994
4124
|
if (text[i] === "@") {
|
|
3995
4125
|
i++;
|
|
3996
|
-
|
|
3997
|
-
const directive =
|
|
3998
|
-
i = directive ?
|
|
4126
|
+
NAME2.lastIndex = i;
|
|
4127
|
+
const directive = NAME2.exec(text);
|
|
4128
|
+
i = directive ? NAME2.lastIndex : i + 1;
|
|
3999
4129
|
if (text[i] === "(") {
|
|
4000
4130
|
let depth = 0;
|
|
4001
4131
|
do {
|
|
@@ -4006,21 +4136,21 @@ var firstOperationField = (text, start, end) => {
|
|
|
4006
4136
|
}
|
|
4007
4137
|
continue;
|
|
4008
4138
|
}
|
|
4009
|
-
|
|
4010
|
-
const match =
|
|
4139
|
+
NAME2.lastIndex = i;
|
|
4140
|
+
const match = NAME2.exec(text);
|
|
4011
4141
|
if (!match) {
|
|
4012
4142
|
i++;
|
|
4013
4143
|
continue;
|
|
4014
4144
|
}
|
|
4015
4145
|
let name = match[0];
|
|
4016
4146
|
let index = match.index;
|
|
4017
|
-
let cursor =
|
|
4147
|
+
let cursor = NAME2.lastIndex;
|
|
4018
4148
|
while (cursor < end && /\s/.test(text[cursor])) cursor++;
|
|
4019
4149
|
if (text[cursor] === ":") {
|
|
4020
4150
|
cursor++;
|
|
4021
4151
|
while (cursor < end && /\s/.test(text[cursor])) cursor++;
|
|
4022
|
-
|
|
4023
|
-
const target =
|
|
4152
|
+
NAME2.lastIndex = cursor;
|
|
4153
|
+
const target = NAME2.exec(text);
|
|
4024
4154
|
if (!target) return void 0;
|
|
4025
4155
|
name = target[0];
|
|
4026
4156
|
index = target.index;
|
|
@@ -4134,7 +4264,7 @@ var extractProtocolAnchors = async (files, source) => {
|
|
|
4134
4264
|
};
|
|
4135
4265
|
|
|
4136
4266
|
// src/core/build.ts
|
|
4137
|
-
var CACHE_VERSION =
|
|
4267
|
+
var CACHE_VERSION = 16;
|
|
4138
4268
|
var IGNORE_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "vendor", ".venv", "venv", "target", "coverage", ".next", "build", "__pycache__", ".pi", ".pi-fovea", "deps", "_build", ".tox", "Pods", ".cargo"]);
|
|
4139
4269
|
var MAX_FILES = envInt("FOVEA_MAX_FILES", 8e3, 100, 1e5);
|
|
4140
4270
|
var MAX_FILE_BYTES = envInt("FOVEA_MAX_FILE_BYTES", 1024 * 1024, 64 * 1024, 64 * 1024 * 1024);
|
|
@@ -4404,7 +4534,7 @@ var emptyFileFacts = (sha1) => ({
|
|
|
4404
4534
|
var pendingFileFacts = (file, sha1, text) => {
|
|
4405
4535
|
const facts = emptyFileFacts(sha1);
|
|
4406
4536
|
const language = langOf(file);
|
|
4407
|
-
if (!language) return facts;
|
|
4537
|
+
if (!language || language === "Bend") return facts;
|
|
4408
4538
|
const sigs = harvestFile(language, text);
|
|
4409
4539
|
if (Object.keys(sigs).length) facts.sigs = sigs;
|
|
4410
4540
|
return facts;
|
|
@@ -4585,8 +4715,8 @@ var extractInto = async (root, store, files, contents, packRules) => {
|
|
|
4585
4715
|
anchors = anchorsFromScan(scanned, anchorPlan, enclosingId);
|
|
4586
4716
|
} else {
|
|
4587
4717
|
[imports, calls, literals, anchors] = await Promise.all([
|
|
4588
|
-
extractImports(code, root),
|
|
4589
|
-
extractCalls(code, root),
|
|
4718
|
+
extractImports(code, root, source),
|
|
4719
|
+
extractCalls(code, root, source),
|
|
4590
4720
|
extractLiterals(batch, root, source),
|
|
4591
4721
|
extractAnchors(code, root, enclosingId, packRules.pack)
|
|
4592
4722
|
]);
|
|
@@ -4981,18 +5111,25 @@ var assembleState = async (root, files, store, extraction, discovery, gitKind, h
|
|
|
4981
5111
|
const stamp = Date.now();
|
|
4982
5112
|
return { root, version, generation, graph, csr, joinIndex, facts, extraction, discovery, adjacency, store, files, gitKind, head, dirty, history, probedAt: stamp, walkedAt: stamp, sweptAt: stamp };
|
|
4983
5113
|
};
|
|
4984
|
-
var
|
|
4985
|
-
|
|
5114
|
+
var requireExtractionBackend = async (files) => {
|
|
5115
|
+
const needed = files.some((file) => {
|
|
5116
|
+
const language = langOf(file);
|
|
5117
|
+
return language !== void 0 && language !== "Bend";
|
|
5118
|
+
});
|
|
5119
|
+
if (needed && !await hasAstGrepAsync()) {
|
|
4986
5120
|
throw new Error(
|
|
4987
5121
|
"fovea: no usable ast-grep binary (set FOVEA_AST_GREP to override; PATH is checked first, then the packaged @ast-grep/cli dependency). Install: https://ast-grep.github.io/"
|
|
4988
5122
|
);
|
|
4989
5123
|
}
|
|
5124
|
+
};
|
|
5125
|
+
var buildState = async (root) => {
|
|
4990
5126
|
const { fileRoutes } = await loadRepoRules(root);
|
|
4991
5127
|
const routeRes = fileRoutes.map((r) => new RegExp(r.re));
|
|
4992
5128
|
const probe = await gitProbe(root);
|
|
4993
5129
|
const gitKind = probe ? "git" : "plain";
|
|
4994
5130
|
const listing = await discoverFiles(root, routeRes, new Set(await readEnrolledBoundaries(root)));
|
|
4995
5131
|
const files = listing.files;
|
|
5132
|
+
await requireExtractionBackend(files);
|
|
4996
5133
|
const { store, report } = await factPass(() => loadFacts(root, files));
|
|
4997
5134
|
return assembleState(
|
|
4998
5135
|
root,
|
|
@@ -5125,6 +5262,7 @@ var refreshState = async (state, hints = [], force = false) => {
|
|
|
5125
5262
|
state.probedAt = Date.now();
|
|
5126
5263
|
return state;
|
|
5127
5264
|
}
|
|
5265
|
+
await requireExtractionBackend(files);
|
|
5128
5266
|
const { report, stats } = await factPass(
|
|
5129
5267
|
() => refreshFacts(state.root, store, files, [...new Set(changed)], [...new Set(deleted)])
|
|
5130
5268
|
);
|
package/package.json
CHANGED
package/src/core/astgrep.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const LANG_BY_EXT: Record<string, string> = {
|
|
|
20
20
|
py: "Python",
|
|
21
21
|
go: "Go",
|
|
22
22
|
rs: "Rust",
|
|
23
|
+
bend: "Bend", // Native source reader; never sent to ast-grep patterns.
|
|
23
24
|
// Second tier: symbols via ast-grep outline; name derivation is heuristic.
|
|
24
25
|
ex: "Elixir", exs: "Elixir",
|
|
25
26
|
rb: "Ruby",
|
package/src/core/bend.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Bend 2 has no ast-grep grammar. Read its indentation-based declarations
|
|
2
|
+
// and explicit references without executing the compiler or fetching packages.
|
|
3
|
+
import type { CallSite, ImportSite, LiteralSite, SymbolRec } from "./types.js";
|
|
4
|
+
|
|
5
|
+
interface BendFacts {
|
|
6
|
+
symbols: SymbolRec[];
|
|
7
|
+
imports: ImportSite[];
|
|
8
|
+
calls: CallSite[];
|
|
9
|
+
literals: LiteralSite[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const NAME = "[A-Za-z_][A-Za-z0-9_.]*";
|
|
13
|
+
const DECL = new RegExp(`^(?:@unsafe\\s+)?(def|law|type)\\s+(${NAME})(?=[\\s(<:]|$)`);
|
|
14
|
+
const CONSTRUCTOR = new RegExp(`^\\s+(${NAME})\\s*\\{`);
|
|
15
|
+
const CALL = new RegExp(`\\b(${NAME})\\s*!?\\s*\\(`, "g");
|
|
16
|
+
const CALL_KEYWORDS = new Set(["def", "type", "law", "match", "case", "do", "return", "for", "exs", "where", "is", "import", "Type", "Data", "Kind", "Quant"]);
|
|
17
|
+
const IMPORT = new RegExp(`^import\\s+(\\S+?)(?:\\s+as\\s+(${NAME}))?\\s*$`);
|
|
18
|
+
|
|
19
|
+
export const extractBend = (file: string, text: string): BendFacts => {
|
|
20
|
+
const facts: BendFacts = { symbols: [], imports: [], calls: [], literals: [] };
|
|
21
|
+
let inType = false;
|
|
22
|
+
let header = false;
|
|
23
|
+
let brackets: string[] = [];
|
|
24
|
+
let quote = "";
|
|
25
|
+
let value = "";
|
|
26
|
+
let literalLine = 0;
|
|
27
|
+
const lines = text.split("\n");
|
|
28
|
+
for (let at = 0; at < lines.length; at++) {
|
|
29
|
+
const raw = lines[at]!;
|
|
30
|
+
const line = at + 1;
|
|
31
|
+
// Mask comments and strings, preserving offsets for foreign import paths.
|
|
32
|
+
// Escapes consume the following character, including an escaped quote.
|
|
33
|
+
let code = "";
|
|
34
|
+
let visible = "";
|
|
35
|
+
const strings: Array<{ start: number; text: string }> = [];
|
|
36
|
+
let start = -1;
|
|
37
|
+
for (let i = 0; i < raw.length; i++) {
|
|
38
|
+
const ch = raw[i]!;
|
|
39
|
+
if (quote) {
|
|
40
|
+
code += " ";
|
|
41
|
+
visible += ch;
|
|
42
|
+
if (ch === "\\" && i + 1 < raw.length) {
|
|
43
|
+
const escaped = raw[++i]!;
|
|
44
|
+
value += ({ n: "\n", r: "\r", t: "\t", "0": "\0" } as Record<string, string>)[escaped] ?? escaped;
|
|
45
|
+
code += " ";
|
|
46
|
+
visible += escaped;
|
|
47
|
+
} else if (ch === quote) {
|
|
48
|
+
if (quote === '"') {
|
|
49
|
+
strings.push({ start, text: value });
|
|
50
|
+
if (value.length >= 2 && value.length <= 200) facts.literals.push({ file, line: literalLine, text: value });
|
|
51
|
+
}
|
|
52
|
+
quote = "";
|
|
53
|
+
} else value += ch;
|
|
54
|
+
} else if (ch === "#") {
|
|
55
|
+
break;
|
|
56
|
+
} else if (ch === '"' || ch === "'") {
|
|
57
|
+
quote = ch;
|
|
58
|
+
value = "";
|
|
59
|
+
literalLine = line;
|
|
60
|
+
start = i;
|
|
61
|
+
code += " ";
|
|
62
|
+
visible += ch;
|
|
63
|
+
} else {
|
|
64
|
+
code += ch;
|
|
65
|
+
visible += ch;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (quote) value += "\n";
|
|
69
|
+
if (!code.trim()) continue;
|
|
70
|
+
const decl = DECL.exec(code);
|
|
71
|
+
if (decl) {
|
|
72
|
+
const kind = decl[1] === "def" ? "function" : decl[1] === "type" ? "type" : "decl";
|
|
73
|
+
const sig = visible.trim();
|
|
74
|
+
facts.symbols.push({ file, line, name: decl[2]!, kind, sig: sig.length > 140 ? sig.slice(0, 137) + "..." : sig, lang: "Bend" });
|
|
75
|
+
inType = decl[1] === "type";
|
|
76
|
+
header = true;
|
|
77
|
+
brackets = [];
|
|
78
|
+
} else if (!header && /^\S/.test(code)) {
|
|
79
|
+
inType = false;
|
|
80
|
+
}
|
|
81
|
+
// A colon inside a parameter annotation does not end a multiline header.
|
|
82
|
+
if (header) {
|
|
83
|
+
for (let i = 0; i < code.length; i++) {
|
|
84
|
+
const ch = code[i]!;
|
|
85
|
+
if ("(<{[".includes(ch)) brackets.push(ch);
|
|
86
|
+
else if (")>}]".includes(ch) && !(ch === ">" && code[i - 1] === "-")) {
|
|
87
|
+
if (brackets.at(-1) === ({ ")": "(", ">": "<", "}": "{", "]": "[" } as Record<string, string>)[ch]) brackets.pop();
|
|
88
|
+
} else if (ch === ":" && brackets.length === 0) {
|
|
89
|
+
header = false;
|
|
90
|
+
code = " ".repeat(i + 1) + code.slice(i + 1);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (header) continue;
|
|
95
|
+
}
|
|
96
|
+
const imp = IMPORT.exec(code);
|
|
97
|
+
if (imp) {
|
|
98
|
+
facts.imports.push({ file, line, spec: imp[1]!, ...(imp[2] ? { alias: imp[2] } : {}) });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/^\s+import\s+/.test(code)) {
|
|
102
|
+
const path = strings.find((s) => s.start >= 0 && /^\s+import\s+$/.test(code.slice(0, s.start)));
|
|
103
|
+
if (path) facts.imports.push({ file, line, spec: path.text });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (inType) {
|
|
107
|
+
const ctor = CONSTRUCTOR.exec(code);
|
|
108
|
+
if (ctor) facts.symbols.push({ file, line, name: ctor[1]!, kind: "decl", sig: visible.trim().slice(0, 140), lang: "Bend" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
CALL.lastIndex = 0;
|
|
112
|
+
for (const call of code.matchAll(CALL)) {
|
|
113
|
+
// Kind is syntax, not a user-defined function. Keep dotted names whole:
|
|
114
|
+
// their dots may be literal name characters, not module boundaries.
|
|
115
|
+
if (!CALL_KEYWORDS.has(call[1]!)) facts.calls.push({ file, line, callee: call[1]! });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// A law and its implementation share a graph identity. Prefer the concrete
|
|
119
|
+
// def's location; likewise a type and its identically named constructor are
|
|
120
|
+
// one symbol, not duplicate nodes with conflicting enclosing locations.
|
|
121
|
+
const symbols = new Map<string, SymbolRec>();
|
|
122
|
+
for (const symbol of facts.symbols) {
|
|
123
|
+
if (!symbols.has(symbol.name) || symbol.kind === "function") symbols.set(symbol.name, symbol);
|
|
124
|
+
}
|
|
125
|
+
facts.symbols = [...symbols.values()].sort((a, b) => a.line - b.line);
|
|
126
|
+
return facts;
|
|
127
|
+
};
|
package/src/core/build.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface FileFacts {
|
|
|
66
66
|
sigs?: FileSigs;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const CACHE_VERSION =
|
|
69
|
+
const CACHE_VERSION = 16; // v16: native Bend 2 facts and explicit module aliases
|
|
70
70
|
|
|
71
71
|
// Honest coverage: what the extractor could NOT see. Tools and status render
|
|
72
72
|
// this so a thin graph never reads as a small repo; omissions are explicit.
|
|
@@ -481,7 +481,8 @@ const emptyFileFacts = (sha1: string): FileFacts => ({
|
|
|
481
481
|
const pendingFileFacts = (file: string, sha1: string, text: string): FileFacts => {
|
|
482
482
|
const facts = emptyFileFacts(sha1);
|
|
483
483
|
const language = langOf(file);
|
|
484
|
-
|
|
484
|
+
// Implicit route rules target ast-grep; native Bend facts have no grammar.
|
|
485
|
+
if (!language || language === "Bend") return facts;
|
|
485
486
|
const sigs = harvestFile(language, text);
|
|
486
487
|
if (Object.keys(sigs).length) facts.sigs = sigs;
|
|
487
488
|
return facts;
|
|
@@ -713,8 +714,8 @@ const extractInto = async (
|
|
|
713
714
|
anchors = anchorsFromScan(scanned, anchorPlan, enclosingId);
|
|
714
715
|
} else {
|
|
715
716
|
[imports, calls, literals, anchors] = await Promise.all([
|
|
716
|
-
extractImports(code, root),
|
|
717
|
-
extractCalls(code, root),
|
|
717
|
+
extractImports(code, root, source),
|
|
718
|
+
extractCalls(code, root, source),
|
|
718
719
|
extractLiterals(batch, root, source),
|
|
719
720
|
extractAnchors(code, root, enclosingId, packRules.pack),
|
|
720
721
|
]);
|
package/src/core/extract.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
anonymousVariadics,
|
|
10
10
|
groupByLang,
|
|
11
11
|
isConfigFile,
|
|
12
|
+
langOf,
|
|
12
13
|
outline,
|
|
13
14
|
outlineStructured,
|
|
14
15
|
patternRunAll,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type ScanRule,
|
|
20
21
|
} from "./astgrep.js";
|
|
21
22
|
import { mapLimit } from "./asyncutil.js";
|
|
23
|
+
import { extractBend } from "./bend.js";
|
|
22
24
|
import { makeFileSource, type FileSource } from "./source.js";
|
|
23
25
|
import type {
|
|
24
26
|
CallSite,
|
|
@@ -287,9 +289,17 @@ const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
|
287
289
|
|
|
288
290
|
const defaultSource = (cwd: string): FileSource => makeFileSource(cwd);
|
|
289
291
|
|
|
292
|
+
const bendFacts = async (files: string[], source: FileSource) =>
|
|
293
|
+
mapLimit(files.filter((file) => langOf(file) === "Bend"), SOURCE_SCAN_CONCURRENCY, async (file) =>
|
|
294
|
+
extractBend(file, await source.read(file) ?? ""));
|
|
295
|
+
|
|
290
296
|
export const extractSymbols = async (files: string[], cwd: string, source: FileSource = defaultSource(cwd)): Promise<SymbolRec[]> => {
|
|
291
297
|
const out: SymbolRec[] = [];
|
|
292
298
|
for (const [lang, langFiles] of groupByLang(files)) {
|
|
299
|
+
if (lang === "Bend") {
|
|
300
|
+
for (const facts of await bendFacts(langFiles, source)) pushAll(out, facts.symbols);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
293
303
|
const structured = await outlineStructured(langFiles, lang, cwd);
|
|
294
304
|
if (structured) {
|
|
295
305
|
const parsed = await parseStructuredOutline(structured, source);
|
|
@@ -327,7 +337,7 @@ const IMPORT_PATTERNS: Record<string, string[]> = {
|
|
|
327
337
|
IMPORT_PATTERNS.JavaScript = IMPORT_PATTERNS.TypeScript!;
|
|
328
338
|
IMPORT_PATTERNS.Tsx = IMPORT_PATTERNS.TypeScript!;
|
|
329
339
|
|
|
330
|
-
export const supportsImportExtraction = (language: string): boolean => !!IMPORT_PATTERNS[language]?.length;
|
|
340
|
+
export const supportsImportExtraction = (language: string): boolean => language === "Bend" || !!IMPORT_PATTERNS[language]?.length;
|
|
331
341
|
|
|
332
342
|
// Only literal paths and one-hole expressions are modeled. Never evaluate code
|
|
333
343
|
// or interpret arbitrary expressions as a static module specifier.
|
|
@@ -382,13 +392,13 @@ const importsFromMatches = (matches: readonly AgMatch[]): ImportSite[] => {
|
|
|
382
392
|
return dedupe(out, (i) => `${i.file}|${i.spec}|${i.line}`);
|
|
383
393
|
};
|
|
384
394
|
|
|
385
|
-
export const extractImports = async (files: string[], cwd: string): Promise<ImportSite[]> => {
|
|
395
|
+
export const extractImports = async (files: string[], cwd: string, source: FileSource = defaultSource(cwd)): Promise<ImportSite[]> => {
|
|
386
396
|
const perLang = await Promise.all([...groupByLang(files)].map(([lang, langFiles]) =>
|
|
387
397
|
patternRunAll(IMPORT_PATTERNS[lang] ?? [], lang, langFiles, cwd),
|
|
388
398
|
));
|
|
389
399
|
const matches: AgMatch[] = [];
|
|
390
400
|
for (const local of perLang) pushAll(matches, local);
|
|
391
|
-
return importsFromMatches(matches);
|
|
401
|
+
return [...importsFromMatches(matches), ...(await bendFacts(files, source)).flatMap((facts) => facts.imports)];
|
|
392
402
|
};
|
|
393
403
|
|
|
394
404
|
const CALL_PATTERNS = ["$O.$M($$$A)", "$F($$$A)"];
|
|
@@ -440,13 +450,13 @@ const callsFromMatches = (matches: readonly AgMatch[]): CallSite[] => {
|
|
|
440
450
|
return out;
|
|
441
451
|
};
|
|
442
452
|
|
|
443
|
-
export const extractCalls = async (files: string[], cwd: string): Promise<CallSite[]> => {
|
|
444
|
-
const perLang = await Promise.all([...groupByLang(files)].map(([lang, langFiles]) =>
|
|
453
|
+
export const extractCalls = async (files: string[], cwd: string, source: FileSource = defaultSource(cwd)): Promise<CallSite[]> => {
|
|
454
|
+
const perLang = await Promise.all([...groupByLang(files)].filter(([lang]) => lang !== "Bend").map(([lang, langFiles]) =>
|
|
445
455
|
patternRunAll(CALL_PATTERNS, lang, langFiles, cwd),
|
|
446
456
|
));
|
|
447
457
|
const matches: AgMatch[] = [];
|
|
448
458
|
for (const local of perLang) pushAll(matches, local);
|
|
449
|
-
return callsFromMatches(matches);
|
|
459
|
+
return [...callsFromMatches(matches), ...(await bendFacts(files, source)).flatMap((facts) => facts.calls)];
|
|
450
460
|
};
|
|
451
461
|
|
|
452
462
|
const STRING_PATTERNS: Record<string, string[]> = {
|
|
@@ -529,7 +539,7 @@ const completeLiterals = async (
|
|
|
529
539
|
out: LiteralSite[],
|
|
530
540
|
): Promise<LiteralSite[]> => {
|
|
531
541
|
pushAll(out, await extractConfigLiterals(files, cwd, source));
|
|
532
|
-
const codeFiles = files.filter((f) => !isConfigFile(f));
|
|
542
|
+
const codeFiles = files.filter((f) => !isConfigFile(f) && langOf(f) !== "Bend");
|
|
533
543
|
const templateSites = await mapLimit(codeFiles, SOURCE_SCAN_CONCURRENCY, async (f) => {
|
|
534
544
|
const local: LiteralSite[] = [];
|
|
535
545
|
const src = await source.read(f);
|
|
@@ -553,7 +563,8 @@ export const extractLiterals = async (files: string[], cwd: string, source: File
|
|
|
553
563
|
));
|
|
554
564
|
const matches: AgMatch[] = [];
|
|
555
565
|
for (const local of perLang) pushAll(matches, local);
|
|
556
|
-
|
|
566
|
+
const native = (await bendFacts(files, source)).flatMap((facts) => facts.literals);
|
|
567
|
+
return completeLiterals(files, cwd, source, [...literalsFromMatches(matches), ...native]);
|
|
557
568
|
};
|
|
558
569
|
|
|
559
570
|
const CORE_IMPORT_PREFIX = "fovea-core-import-";
|
|
@@ -577,6 +588,7 @@ export const coreScanRules = (files: string[]): ScanRule[] => {
|
|
|
577
588
|
});
|
|
578
589
|
};
|
|
579
590
|
for (const [language] of groupByLang(files)) {
|
|
591
|
+
if (language === "Bend") continue;
|
|
580
592
|
for (const pattern of IMPORT_PATTERNS[language] ?? []) add(CORE_IMPORT_PREFIX, language, pattern);
|
|
581
593
|
for (const pattern of CALL_PATTERNS) {
|
|
582
594
|
const metavar = pattern.startsWith("$O.") ? "M" : "F";
|
|
@@ -607,10 +619,11 @@ export const coreFactsFromScan = async (
|
|
|
607
619
|
else if (match.ruleId.startsWith(CORE_CALL_PREFIX)) calls.push(match);
|
|
608
620
|
else if (match.ruleId.startsWith(CORE_LITERAL_PREFIX)) literals.push(match);
|
|
609
621
|
}
|
|
622
|
+
const native = await bendFacts(files, source);
|
|
610
623
|
return {
|
|
611
|
-
imports: importsFromMatches(imports),
|
|
612
|
-
calls: callsFromMatches(calls),
|
|
613
|
-
literals: await completeLiterals(files, cwd, source, literalsFromMatches(literals)),
|
|
624
|
+
imports: [...importsFromMatches(imports), ...native.flatMap((facts) => facts.imports)],
|
|
625
|
+
calls: [...callsFromMatches(calls), ...native.flatMap((facts) => facts.calls)],
|
|
626
|
+
literals: await completeLiterals(files, cwd, source, [...literalsFromMatches(literals), ...native.flatMap((facts) => facts.literals)]),
|
|
614
627
|
};
|
|
615
628
|
};
|
|
616
629
|
|
package/src/core/graph.ts
CHANGED
|
@@ -79,6 +79,7 @@ const buildImportIndex = (files: string[]): ImportIndex => {
|
|
|
79
79
|
|
|
80
80
|
interface ImportResolution {
|
|
81
81
|
file: string;
|
|
82
|
+
alias?: string;
|
|
82
83
|
evidence: EdgeEvidence;
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -95,6 +96,14 @@ const resolveImportToFile = (
|
|
|
95
96
|
? { file: hits[0]!, evidence: { strategy, rule: "import-resolve", source: spec, candidates: hits.length } }
|
|
96
97
|
: undefined;
|
|
97
98
|
};
|
|
99
|
+
if (fam === "bend") {
|
|
100
|
+
// Base is bundled by the compiler; hub packages are external. Neither
|
|
101
|
+
// should accidentally resolve via the TypeScript bare-specifier fallback.
|
|
102
|
+
if (spec === "Base" || /^0x[0-9a-f]+\//i.test(spec) || posix.isAbsolute(spec)) return undefined;
|
|
103
|
+
const local = posix.normalize(posix.join(posix.dirname(fromFile), spec));
|
|
104
|
+
if (local === ".." || local.startsWith("../")) return undefined;
|
|
105
|
+
return exact([local], "relative-import");
|
|
106
|
+
}
|
|
98
107
|
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
99
108
|
let base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
|
|
100
109
|
// NodeNext convention: TS files import "./sibling.js" — the .js refers to
|
|
@@ -335,7 +344,7 @@ export const assembleGraphWithIndex = async (
|
|
|
335
344
|
importCoverage.resolved++;
|
|
336
345
|
if (target.file === rel) continue;
|
|
337
346
|
pushEdge(fileIdx.get(rel)!, fileIdx.get(target.file)!, "imports", 0.3, target.evidence);
|
|
338
|
-
(importTargets.get(rel) ?? importTargets.set(rel, []).get(rel)!).push(target);
|
|
347
|
+
(importTargets.get(rel) ?? importTargets.set(rel, []).get(rel)!).push({ ...target, alias: imp.alias });
|
|
339
348
|
}
|
|
340
349
|
if (isTestFile(rel)) {
|
|
341
350
|
for (const target of importTargets.get(rel) ?? []) {
|
|
@@ -356,9 +365,15 @@ export const assembleGraphWithIndex = async (
|
|
|
356
365
|
await forEachChunked(files, 256, (rel) => {
|
|
357
366
|
const f = facts(rel);
|
|
358
367
|
if (!f) return;
|
|
359
|
-
const
|
|
368
|
+
const targets = importTargets.get(rel) ?? [];
|
|
369
|
+
const imported = new Set(targets.map((target) => target.file));
|
|
370
|
+
const bend = langFamily(rel) === "bend";
|
|
371
|
+
const aliases = bend ? targets.filter((target) => target.alias).sort((a, b) => b.alias!.length - a.alias!.length) : [];
|
|
360
372
|
for (const call of f.calls) {
|
|
361
|
-
const
|
|
373
|
+
const alias = aliases.find((target) => call.callee.startsWith(`${target.alias}.`));
|
|
374
|
+
const name = alias ? call.callee.slice(alias.alias!.length + 1) : call.callee;
|
|
375
|
+
const cands = (byName.get(name.toLowerCase()) ?? []).filter((i) => !bend ||
|
|
376
|
+
(nodes[i]!.lang === "Bend" && nodes[i]!.name === name && nodes[i]!.file === (alias?.file ?? rel)));
|
|
362
377
|
if (!cands.length || cands.length > 48) continue;
|
|
363
378
|
let strategy: EdgeEvidence["strategy"] = "same-file-symbol";
|
|
364
379
|
let chosen: number[] = cands.filter((i) => nodes[i]!.file === rel);
|
package/src/core/state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { hasAstGrepAsync } from "./astgrep.js";
|
|
4
|
+
import { hasAstGrepAsync, langOf } from "./astgrep.js";
|
|
5
5
|
import {
|
|
6
6
|
clearPersistTimer,
|
|
7
7
|
discoverFiles,
|
|
@@ -189,12 +189,21 @@ const assembleState = async (
|
|
|
189
189
|
return { root, version, generation, graph, csr, joinIndex, facts, extraction, discovery, adjacency, store, files, gitKind, head, dirty, history, probedAt: stamp, walkedAt: stamp, sweptAt: stamp };
|
|
190
190
|
};
|
|
191
191
|
|
|
192
|
-
const
|
|
193
|
-
|
|
192
|
+
const requireExtractionBackend = async (files: readonly string[]): Promise<void> => {
|
|
193
|
+
// Bend and config/protocol readers are native; do not even probe a binary
|
|
194
|
+
// until discovery finds a language whose facts depend on ast-grep.
|
|
195
|
+
const needed = files.some((file) => {
|
|
196
|
+
const language = langOf(file);
|
|
197
|
+
return language !== undefined && language !== "Bend";
|
|
198
|
+
});
|
|
199
|
+
if (needed && !(await hasAstGrepAsync())) {
|
|
194
200
|
throw new Error(
|
|
195
201
|
"fovea: no usable ast-grep binary (set FOVEA_AST_GREP to override; PATH is checked first, then the packaged @ast-grep/cli dependency). Install: https://ast-grep.github.io/",
|
|
196
202
|
);
|
|
197
203
|
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const buildState = async (root: string): Promise<RepoState> => {
|
|
198
207
|
const { fileRoutes } = await loadRepoRules(root);
|
|
199
208
|
const routeRes = fileRoutes.map((r) => new RegExp(r.re));
|
|
200
209
|
const probe = await gitProbe(root);
|
|
@@ -203,6 +212,7 @@ const buildState = async (root: string): Promise<RepoState> => {
|
|
|
203
212
|
// root had enrolled, so a restart restores coverage without a fresh edit.
|
|
204
213
|
const listing = await discoverFiles(root, routeRes, new Set(await readEnrolledBoundaries(root)));
|
|
205
214
|
const files = listing.files;
|
|
215
|
+
await requireExtractionBackend(files);
|
|
206
216
|
const { store, report } = await factPass(() => loadFacts(root, files));
|
|
207
217
|
return assembleState(root, files, store, report, listing.report, gitKind, probe?.head,
|
|
208
218
|
new Set(probe ? probe.changes.map((c) => c.path).filter((p) => p && !p.endsWith("/")) : []));
|
|
@@ -362,6 +372,7 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
|
|
|
362
372
|
state.probedAt = Date.now();
|
|
363
373
|
return state;
|
|
364
374
|
}
|
|
375
|
+
await requireExtractionBackend(files);
|
|
365
376
|
const { report, stats } = await factPass(() =>
|
|
366
377
|
refreshFacts(state.root, store, files, [...new Set(changed)], [...new Set(deleted)]),
|
|
367
378
|
);
|
package/src/core/sync.ts
CHANGED
|
@@ -293,7 +293,7 @@ const semanticFacts = (state: RepoState, file: string): string => {
|
|
|
293
293
|
const compactSig = (sig: string): string => sig.replace(/\s+/g, " ").trim();
|
|
294
294
|
const value = createHash("sha1").update(JSON.stringify({
|
|
295
295
|
symbols: stable(facts.symbols.map((symbol) => [symbol.name, symbol.kind, compactSig(symbol.sig), symbol.lang])),
|
|
296
|
-
imports: stable(facts.imports.map((site) => [site.spec])),
|
|
296
|
+
imports: stable(facts.imports.map((site) => [site.spec, site.alias])),
|
|
297
297
|
calls: stable(facts.calls.map((site) => [site.callee])),
|
|
298
298
|
literals: stable(facts.literals.map((site) => [site.text])),
|
|
299
299
|
anchors: stable(facts.anchors.map((anchor) => [anchor.id, anchor.kind, anchor.nodeId, anchor.implicit === true])),
|
package/src/core/types.ts
CHANGED
|
@@ -128,6 +128,8 @@ export interface ImportSite {
|
|
|
128
128
|
file: string;
|
|
129
129
|
spec: string;
|
|
130
130
|
line: number;
|
|
131
|
+
/** Local module name for Bend's explicit `import path as Name`. */
|
|
132
|
+
alias?: string;
|
|
131
133
|
/** Captured computed expression; absent bounds mean the target is unresolved. */
|
|
132
134
|
dynamic?: { prefix?: string; suffix?: string };
|
|
133
135
|
}
|