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 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
- return completeLiterals(files, cwd, source, literalsFromMatches(matches));
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 imported = new Set((importTargets.get(rel) ?? []).map((target) => target.file));
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 cands = byName.get(call.callee.toLowerCase()) ?? [];
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 NAME = /[_A-Za-z][_0-9A-Za-z]*/y;
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
- NAME.lastIndex = i;
3925
- const match = NAME.exec(text);
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 = NAME.lastIndex;
3932
- let resume = NAME.lastIndex;
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
- NAME.lastIndex = i;
3960
- const spread = NAME.exec(text);
3961
- i = spread ? NAME.lastIndex : i + 1;
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
- NAME.lastIndex = i;
3965
- const typeCondition = NAME.exec(text);
3966
- i = typeCondition ? NAME.lastIndex : i;
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
- NAME.lastIndex = i;
3972
- const directive = NAME.exec(text);
3973
- i = directive ? NAME.lastIndex : i + 1;
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
- NAME.lastIndex = i;
3997
- const directive = NAME.exec(text);
3998
- i = directive ? NAME.lastIndex : i + 1;
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
- NAME.lastIndex = i;
4010
- const match = NAME.exec(text);
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 = NAME.lastIndex;
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
- NAME.lastIndex = cursor;
4023
- const target = NAME.exec(text);
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 = 15;
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 buildState = async (root) => {
4985
- if (!await hasAstGrepAsync()) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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",
@@ -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 = 15; // v15: literal dynamic imports and bounded computed import expressions
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
- if (!language) return facts;
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
  ]);
@@ -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
- return completeLiterals(files, cwd, source, literalsFromMatches(matches));
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 imported = new Set((importTargets.get(rel) ?? []).map((target) => target.file));
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 cands = byName.get(call.callee.toLowerCase()) ?? [];
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 buildState = async (root: string): Promise<RepoState> => {
193
- if (!(await hasAstGrepAsync())) {
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
  }