@pubinfo-pr/devtools 0.171.3 → 0.171.5

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/dist/index.mjs CHANGED
@@ -3,8 +3,8 @@ import sirv from "sirv";
3
3
  import { basename, dirname, extname, join, relative } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
- import { readdir } from "node:fs/promises";
7
- import { js, ts, tsx } from "@ast-grep/napi";
6
+ import { readFile, readdir, stat } from "node:fs/promises";
7
+ import { Lang, parse } from "@ast-grep/napi";
8
8
 
9
9
  //#region src/server/controller/api.ts
10
10
  const REQUEST_MODULE_EXTENSIONS = [
@@ -1483,8 +1483,27 @@ const components = [...vueComponent, ...pubinfoComponent];
1483
1483
 
1484
1484
  //#endregion
1485
1485
  //#region src/server/utils/ast-analyzer.ts
1486
+ /** 跳过的目录 */
1487
+ const IGNORED_DIRS = new Set([
1488
+ "node_modules",
1489
+ "dist",
1490
+ "build",
1491
+ ".git",
1492
+ "coverage",
1493
+ ".pubinfo"
1494
+ ]);
1495
+ /** 默认源码扩展名 */
1496
+ const SOURCE_EXTENSIONS$2 = new Set([
1497
+ ".ts",
1498
+ ".tsx",
1499
+ ".js",
1500
+ ".jsx",
1501
+ ".vue"
1502
+ ]);
1503
+ /** 单次并发读取文件数(防止文件描述符爆满) */
1504
+ const FILE_READ_CONCURRENCY = 64;
1486
1505
  /**
1487
- * 递归扫描目录获取所有源文件
1506
+ * 递归扫描目录获取所有源文件(同步版本,保留给 module-counter 使用)
1488
1507
  */
1489
1508
  function getAllSourceFiles(dir, extensions = [
1490
1509
  ".ts",
@@ -1493,6 +1512,7 @@ function getAllSourceFiles(dir, extensions = [
1493
1512
  ".jsx",
1494
1513
  ".vue"
1495
1514
  ]) {
1515
+ const extSet = new Set(extensions);
1496
1516
  const files = [];
1497
1517
  function scan(currentDir) {
1498
1518
  try {
@@ -1500,128 +1520,214 @@ function getAllSourceFiles(dir, extensions = [
1500
1520
  for (const entry of entries) {
1501
1521
  const fullPath = join(currentDir, entry);
1502
1522
  try {
1503
- const stat = statSync(fullPath);
1504
- if (stat.isDirectory()) {
1505
- if (![
1506
- "node_modules",
1507
- "dist",
1508
- "build",
1509
- ".git",
1510
- "coverage",
1511
- ".pubinfo"
1512
- ].includes(entry)) scan(fullPath);
1513
- } else if (stat.isFile()) {
1514
- const ext = extname(fullPath);
1515
- if (extensions.includes(ext)) files.push(fullPath);
1516
- }
1517
- } catch (error) {
1518
- console.warn(`无法访问: ${fullPath}`, error);
1519
- }
1523
+ const s = statSync(fullPath);
1524
+ if (s.isDirectory()) {
1525
+ if (!IGNORED_DIRS.has(entry)) scan(fullPath);
1526
+ } else if (s.isFile() && extSet.has(extname(fullPath))) files.push(fullPath);
1527
+ } catch {}
1520
1528
  }
1521
- } catch (error) {
1522
- console.warn(`无法读取目录: ${currentDir}`, error);
1523
- }
1529
+ } catch {}
1524
1530
  }
1525
1531
  scan(dir);
1526
1532
  return files;
1527
1533
  }
1528
1534
  /**
1529
- * 根据文件扩展名选择解析器
1535
+ * 异步递归扫描目录获取所有源文件
1530
1536
  */
1531
- function getParser(filePath) {
1537
+ async function getAllSourceFilesAsync(dir) {
1538
+ const files = [];
1539
+ async function scan(currentDir) {
1540
+ let entries;
1541
+ try {
1542
+ entries = await readdir(currentDir);
1543
+ } catch {
1544
+ return;
1545
+ }
1546
+ const statJobs = entries.map(async (entry) => {
1547
+ const fullPath = join(currentDir, entry);
1548
+ try {
1549
+ const s = await stat(fullPath);
1550
+ if (s.isDirectory()) {
1551
+ if (!IGNORED_DIRS.has(entry)) await scan(fullPath);
1552
+ } else if (s.isFile() && SOURCE_EXTENSIONS$2.has(extname(fullPath))) files.push(fullPath);
1553
+ } catch {}
1554
+ });
1555
+ await Promise.all(statJobs);
1556
+ }
1557
+ await scan(dir);
1558
+ return files;
1559
+ }
1560
+ /**
1561
+ * 根据文件扩展名选择语言类型
1562
+ */
1563
+ function getLang(filePath) {
1532
1564
  switch (extname(filePath)) {
1533
1565
  case ".tsx":
1534
- case ".jsx": return tsx;
1535
- case ".js": return js;
1566
+ case ".jsx": return Lang.Tsx;
1567
+ case ".js": return Lang.JavaScript;
1536
1568
  case ".ts":
1537
1569
  case ".vue":
1538
- default: return ts;
1570
+ default: return Lang.TypeScript;
1539
1571
  }
1540
1572
  }
1541
1573
  /**
1542
- * 分析 Vue 组件使用情况
1574
+ * 在文件内容上做一次快速字符串检查,返回内容中可能出现的追踪项名称集合。
1575
+ * 通过这一步可以跳过绝大部分不相关的文件,避免无效 AST 解析。
1576
+ */
1577
+ function quickFilterNames(content, names) {
1578
+ const hits = /* @__PURE__ */ new Set();
1579
+ for (const name of names) if (content.includes(name)) hits.add(name);
1580
+ return hits;
1581
+ }
1582
+ /**
1583
+ * 对单个文件做一次性全量分析,返回该文件中出现的所有追踪名的使用位置。
1584
+ *
1585
+ * 优化点:
1586
+ * 1. 先用字符串 includes 快速预过滤,跳过不相关名称。
1587
+ * 2. AST 只解析一次,遍历一次 rootNode,收集所有 call_expression / import_specifier / jsx。
1588
+ * 3. Vue template 部分改为一次性合并正则匹配(而非 N 次独立正则)。
1543
1589
  */
1544
- function analyzeComponentUsage(content, componentName, filePath) {
1545
- const locations = [];
1590
+ function analyzeFile(content, filePath, functionNames, componentNames) {
1591
+ const result = {
1592
+ functions: /* @__PURE__ */ new Map(),
1593
+ components: /* @__PURE__ */ new Map()
1594
+ };
1595
+ const hitFunctions = quickFilterNames(content, functionNames);
1596
+ const hitComponents = quickFilterNames(content, componentNames);
1597
+ if (hitFunctions.size === 0 && hitComponents.size === 0) return result;
1546
1598
  try {
1547
- const matches = getParser(filePath).parse(content).root().findAll({ rule: {
1548
- kind: "jsx_opening_element",
1549
- pattern: `<${componentName}`
1550
- } });
1551
- for (const match of matches) {
1552
- const range = match.range();
1553
- locations.push({
1554
- file: filePath,
1555
- line: range.start.line + 1,
1556
- column: range.start.column + 1,
1557
- code: match.text().slice(0, 100)
1558
- });
1599
+ const rootNode = parse(getLang(filePath), content).root();
1600
+ if (hitFunctions.size > 0) {
1601
+ const callMatches = rootNode.findAll({ rule: { kind: "call_expression" } });
1602
+ for (const match of callMatches) {
1603
+ const calleeName = match.field("function")?.text();
1604
+ if (calleeName && hitFunctions.has(calleeName)) {
1605
+ const range = match.range();
1606
+ const loc = {
1607
+ file: filePath,
1608
+ line: range.start.line + 1,
1609
+ column: range.start.column + 1,
1610
+ code: match.text().slice(0, 100)
1611
+ };
1612
+ if (!result.functions.has(calleeName)) result.functions.set(calleeName, []);
1613
+ result.functions.get(calleeName).push(loc);
1614
+ }
1615
+ }
1616
+ const importMatches = rootNode.findAll({ rule: { kind: "import_specifier" } });
1617
+ for (const match of importMatches) {
1618
+ const text = match.text();
1619
+ const specName = text.split(/\s/)[0];
1620
+ if (specName && hitFunctions.has(specName)) {
1621
+ const range = match.range();
1622
+ const loc = {
1623
+ file: filePath,
1624
+ line: range.start.line + 1,
1625
+ column: range.start.column + 1,
1626
+ code: text
1627
+ };
1628
+ if (!result.functions.has(specName)) result.functions.set(specName, []);
1629
+ result.functions.get(specName).push(loc);
1630
+ }
1631
+ }
1559
1632
  }
1560
- } catch (error) {}
1561
- if (filePath.endsWith(".vue")) {
1562
- const templateRegex = new RegExp(`<${componentName}[\\s>]`, "g");
1633
+ if (hitComponents.size > 0) {
1634
+ const jsxMatches = rootNode.findAll({ rule: { kind: "jsx_opening_element" } });
1635
+ for (const match of jsxMatches) {
1636
+ const text = match.text();
1637
+ const nameMatch = text.match(/^<(\w+)/);
1638
+ if (nameMatch && hitComponents.has(nameMatch[1])) {
1639
+ const compName = nameMatch[1];
1640
+ const range = match.range();
1641
+ const loc = {
1642
+ file: filePath,
1643
+ line: range.start.line + 1,
1644
+ column: range.start.column + 1,
1645
+ code: text.slice(0, 100)
1646
+ };
1647
+ if (!result.components.has(compName)) result.components.set(compName, []);
1648
+ result.components.get(compName).push(loc);
1649
+ }
1650
+ }
1651
+ }
1652
+ } catch {}
1653
+ if (filePath.endsWith(".vue") && hitComponents.size > 0) {
1654
+ const names = Array.from(hitComponents);
1655
+ const unionRegex = new RegExp(`<(${names.map(escapeRegex).join("|")})[\\s>/]`, "g");
1563
1656
  const lines = content.split("\n");
1564
- let match = templateRegex.exec(content);
1657
+ const lineOffsets = buildLineOffsets(lines);
1658
+ let match = unionRegex.exec(content);
1565
1659
  while (match !== null) {
1660
+ const compName = match[1];
1566
1661
  const pos = match.index;
1567
- let lineNum = 0;
1568
- let charCount = 0;
1569
- for (let i = 0; i < lines.length; i++) {
1570
- charCount += lines[i].length + 1;
1571
- if (charCount > pos) {
1572
- lineNum = i + 1;
1573
- break;
1574
- }
1575
- }
1576
- locations.push({
1662
+ const lineNum = findLineNumber(lineOffsets, pos);
1663
+ const loc = {
1577
1664
  file: filePath,
1578
1665
  line: lineNum,
1579
- column: pos - (charCount - lines[lineNum - 1].length - 1) + 1,
1666
+ column: pos - lineOffsets[lineNum - 1] + 1,
1580
1667
  code: lines[lineNum - 1].trim().slice(0, 100)
1581
- });
1582
- match = templateRegex.exec(content);
1668
+ };
1669
+ if (!result.components.has(compName)) result.components.set(compName, []);
1670
+ result.components.get(compName).push(loc);
1671
+ match = unionRegex.exec(content);
1583
1672
  }
1584
1673
  }
1585
- return locations;
1674
+ return result;
1675
+ }
1676
+ /** 转义正则特殊字符 */
1677
+ function escapeRegex(str) {
1678
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1679
+ }
1680
+ /** 构建每行起始偏移量数组,索引 0 = 第 1 行起始偏移 */
1681
+ function buildLineOffsets(lines) {
1682
+ const offsets = Array.from({ length: lines.length });
1683
+ offsets[0] = 0;
1684
+ for (let i = 1; i < lines.length; i++) offsets[i] = offsets[i - 1] + lines[i - 1].length + 1;
1685
+ return offsets;
1686
+ }
1687
+ /** 通过二分查找快速定位字符偏移所在的行号(1-based) */
1688
+ function findLineNumber(lineOffsets, pos) {
1689
+ let lo = 0;
1690
+ let hi = lineOffsets.length - 1;
1691
+ while (lo < hi) {
1692
+ const mid = lo + hi + 1 >> 1;
1693
+ if (lineOffsets[mid] <= pos) lo = mid;
1694
+ else hi = mid - 1;
1695
+ }
1696
+ return lo + 1;
1697
+ }
1698
+ /**
1699
+ * 以指定并发度分批执行异步任务
1700
+ */
1701
+ async function processBatched(items, concurrency, fn) {
1702
+ const results = [];
1703
+ for (let i = 0; i < items.length; i += concurrency) {
1704
+ const batch = items.slice(i, i + concurrency);
1705
+ const batchResults = await Promise.all(batch.map(fn));
1706
+ results.push(...batchResults);
1707
+ }
1708
+ return results;
1586
1709
  }
1587
1710
  /**
1588
- * 分析函数/方法使用情况
1711
+ * 扫描单个文件的使用情况。用于增量更新场景。
1589
1712
  */
1590
- function analyzeFunctionUsage(content, functionName, filePath) {
1591
- const locations = [];
1713
+ async function scanSingleFile(filePath, functionsToTrack, componentsToTrack) {
1714
+ const ext = extname(filePath);
1715
+ if (!SOURCE_EXTENSIONS$2.has(ext)) return null;
1592
1716
  try {
1593
- const root = getParser(filePath).parse(content);
1594
- const callMatches = root.root().findAll({ rule: {
1595
- kind: "call_expression",
1596
- pattern: `${functionName}($$$)`
1597
- } });
1598
- for (const match of callMatches) {
1599
- const range = match.range();
1600
- locations.push({
1601
- file: filePath,
1602
- line: range.start.line + 1,
1603
- column: range.start.column + 1,
1604
- code: match.text().slice(0, 100)
1605
- });
1606
- }
1607
- const importMatches = root.root().findAll({ rule: {
1608
- kind: "import_specifier",
1609
- pattern: functionName
1610
- } });
1611
- for (const match of importMatches) {
1612
- const range = match.range();
1613
- locations.push({
1614
- file: filePath,
1615
- line: range.start.line + 1,
1616
- column: range.start.column + 1,
1617
- code: match.text()
1618
- });
1619
- }
1620
- } catch (error) {}
1621
- return locations;
1717
+ return analyzeFile(await readFile(filePath, "utf-8"), filePath, new Set(functionsToTrack.map((f) => f.name)), new Set(componentsToTrack.map((c) => c.name)));
1718
+ } catch {
1719
+ return null;
1720
+ }
1622
1721
  }
1623
1722
  /**
1624
- * 扫描项目中的使用情况
1723
+ * 扫描项目中的使用情况(优化版)
1724
+ *
1725
+ * 相比旧版的优化:
1726
+ * 1. 异步 I/O + 并发读取文件,不阻塞 Vite dev server。
1727
+ * 2. 每文件只做一次 AST 解析,遍历一次收集所有命中。
1728
+ * 3. 快速预过滤(string.includes)跳过不相关文件。
1729
+ * 4. Vue template 使用联合正则一次匹配,二分法定位行号。
1730
+ *
1625
1731
  * @param projectRoot 项目根目录
1626
1732
  * @param functionsToTrack 要追踪的函数列表
1627
1733
  * @param componentsToTrack 要追踪的组件列表
@@ -1633,8 +1739,10 @@ async function scanUsageStats(projectRoot, functionsToTrack, componentsToTrack)
1633
1739
  totalFiles: 0,
1634
1740
  scannedFiles: 0
1635
1741
  };
1636
- const files = getAllSourceFiles(projectRoot);
1742
+ const files = await getAllSourceFilesAsync(projectRoot);
1637
1743
  result.totalFiles = files.length;
1744
+ const functionNames = new Set(functionsToTrack.map((f) => f.name));
1745
+ const componentNames = new Set(componentsToTrack.map((c) => c.name));
1638
1746
  const functionStatsMap = /* @__PURE__ */ new Map();
1639
1747
  const componentStatsMap = /* @__PURE__ */ new Map();
1640
1748
  for (const func of functionsToTrack) functionStatsMap.set(func.name, {
@@ -1653,28 +1761,27 @@ async function scanUsageStats(projectRoot, functionsToTrack, componentsToTrack)
1653
1761
  locations: [],
1654
1762
  meta: comp.meta
1655
1763
  });
1656
- for (const file of files) try {
1657
- const content = readFileSync(file, "utf-8");
1658
- result.scannedFiles++;
1659
- for (const func of functionsToTrack) {
1660
- const locations = analyzeFunctionUsage(content, func.name, file);
1661
- if (locations.length > 0) {
1662
- const stats = functionStatsMap.get(func.name);
1663
- stats.locations.push(...locations);
1664
- stats.count += locations.length;
1764
+ await processBatched(files, FILE_READ_CONCURRENCY, async (file) => {
1765
+ try {
1766
+ const content = await readFile(file, "utf-8");
1767
+ result.scannedFiles++;
1768
+ const analysis = analyzeFile(content, file, functionNames, componentNames);
1769
+ for (const [name, locations] of analysis.functions) {
1770
+ const stats = functionStatsMap.get(name);
1771
+ if (stats) {
1772
+ stats.locations.push(...locations);
1773
+ stats.count += locations.length;
1774
+ }
1665
1775
  }
1666
- }
1667
- for (const comp of componentsToTrack) {
1668
- const locations = analyzeComponentUsage(content, comp.name, file);
1669
- if (locations.length > 0) {
1670
- const stats = componentStatsMap.get(comp.name);
1671
- stats.locations.push(...locations);
1672
- stats.count += locations.length;
1776
+ for (const [name, locations] of analysis.components) {
1777
+ const stats = componentStatsMap.get(name);
1778
+ if (stats) {
1779
+ stats.locations.push(...locations);
1780
+ stats.count += locations.length;
1781
+ }
1673
1782
  }
1674
- }
1675
- } catch (error) {
1676
- console.warn(`无法读取文件: ${file}`, error);
1677
- }
1783
+ } catch {}
1784
+ });
1678
1785
  result.functions = Array.from(functionStatsMap.values());
1679
1786
  result.components = Array.from(componentStatsMap.values());
1680
1787
  return result;
@@ -1700,7 +1807,7 @@ function countRegisteredModules(projectRoot) {
1700
1807
  }
1701
1808
  if (!content.includes("createPubinfo")) continue;
1702
1809
  try {
1703
- const rootNode = getParser(filePath).parse(content).root();
1810
+ const rootNode = parse(getLang(filePath), content).root();
1704
1811
  const context = {
1705
1812
  rootNode,
1706
1813
  initializerCache: /* @__PURE__ */ new Map()
@@ -1816,6 +1923,139 @@ function getFirstNamedChild(node) {
1816
1923
 
1817
1924
  //#endregion
1818
1925
  //#region src/server/controller/usage.ts
1926
+ /** 源码扩展名集合,用于判断文件变更是否需要触发增量更新 */
1927
+ const SOURCE_EXTENSIONS$1 = new Set([
1928
+ ".ts",
1929
+ ".tsx",
1930
+ ".js",
1931
+ ".jsx",
1932
+ ".vue"
1933
+ ]);
1934
+ let _cache = null;
1935
+ let _scanPromise = null;
1936
+ /**
1937
+ * 获取或初始化缓存。
1938
+ * 多个并发请求只会触发一次扫描(Promise 去重)。
1939
+ */
1940
+ async function getOrCreateCache(projectRoot) {
1941
+ if (_cache) return _cache;
1942
+ if (_scanPromise) return _scanPromise;
1943
+ _scanPromise = (async () => {
1944
+ const stats = await scanUsageStats(projectRoot, functions, components);
1945
+ _cache = {
1946
+ stats,
1947
+ fileHits: buildFileHitsIndex(stats),
1948
+ timestamp: Date.now()
1949
+ };
1950
+ _scanPromise = null;
1951
+ return _cache;
1952
+ })();
1953
+ return _scanPromise;
1954
+ }
1955
+ /**
1956
+ * 从扫描结果构建 "文件 → 命中" 索引,用于增量更新时快速清除旧数据。
1957
+ */
1958
+ function buildFileHitsIndex(stats) {
1959
+ const map = /* @__PURE__ */ new Map();
1960
+ for (const item of stats.functions) for (const loc of item.locations) {
1961
+ if (!map.has(loc.file)) map.set(loc.file, {
1962
+ functions: /* @__PURE__ */ new Map(),
1963
+ components: /* @__PURE__ */ new Map()
1964
+ });
1965
+ const entry = map.get(loc.file);
1966
+ entry.functions.set(item.name, (entry.functions.get(item.name) ?? 0) + 1);
1967
+ }
1968
+ for (const item of stats.components) for (const loc of item.locations) {
1969
+ if (!map.has(loc.file)) map.set(loc.file, {
1970
+ functions: /* @__PURE__ */ new Map(),
1971
+ components: /* @__PURE__ */ new Map()
1972
+ });
1973
+ const entry = map.get(loc.file);
1974
+ entry.components.set(item.name, (entry.components.get(item.name) ?? 0) + 1);
1975
+ }
1976
+ return map;
1977
+ }
1978
+ /**
1979
+ * 增量更新:当单个文件发生变更时,只重新扫描该文件。
1980
+ *
1981
+ * 流程:
1982
+ * 1. 从缓存中清除该文件的旧贡献值。
1983
+ * 2. 重新扫描该文件。
1984
+ * 3. 把新结果合并回缓存。
1985
+ */
1986
+ async function incrementalUpdate(filePath) {
1987
+ if (!_cache) return;
1988
+ const ext = extname(filePath);
1989
+ if (!SOURCE_EXTENSIONS$1.has(ext)) return;
1990
+ const { stats, fileHits } = _cache;
1991
+ const oldEntry = fileHits.get(filePath);
1992
+ if (oldEntry) {
1993
+ for (const [name, count] of oldEntry.functions) {
1994
+ const item = stats.functions.find((f) => f.name === name);
1995
+ if (item) {
1996
+ item.count -= count;
1997
+ item.locations = item.locations.filter((loc) => loc.file !== filePath);
1998
+ }
1999
+ }
2000
+ for (const [name, count] of oldEntry.components) {
2001
+ const item = stats.components.find((c) => c.name === name);
2002
+ if (item) {
2003
+ item.count -= count;
2004
+ item.locations = item.locations.filter((loc) => loc.file !== filePath);
2005
+ }
2006
+ }
2007
+ fileHits.delete(filePath);
2008
+ }
2009
+ const result = await scanSingleFile(filePath, functions, components);
2010
+ if (!result) return;
2011
+ const newEntry = {
2012
+ functions: /* @__PURE__ */ new Map(),
2013
+ components: /* @__PURE__ */ new Map()
2014
+ };
2015
+ for (const [name, locations] of result.functions) {
2016
+ const item = stats.functions.find((f) => f.name === name);
2017
+ if (item) {
2018
+ item.locations.push(...locations);
2019
+ item.count += locations.length;
2020
+ }
2021
+ newEntry.functions.set(name, locations.length);
2022
+ }
2023
+ for (const [name, locations] of result.components) {
2024
+ const item = stats.components.find((c) => c.name === name);
2025
+ if (item) {
2026
+ item.locations.push(...locations);
2027
+ item.count += locations.length;
2028
+ }
2029
+ newEntry.components.set(name, locations.length);
2030
+ }
2031
+ if (newEntry.functions.size > 0 || newEntry.components.size > 0) fileHits.set(filePath, newEntry);
2032
+ _cache.timestamp = Date.now();
2033
+ }
2034
+ /**
2035
+ * 增量删除:当文件被删除时,清除其贡献。
2036
+ */
2037
+ async function incrementalRemove(filePath) {
2038
+ if (!_cache) return;
2039
+ const { stats, fileHits } = _cache;
2040
+ const oldEntry = fileHits.get(filePath);
2041
+ if (!oldEntry) return;
2042
+ for (const [name, count] of oldEntry.functions) {
2043
+ const item = stats.functions.find((f) => f.name === name);
2044
+ if (item) {
2045
+ item.count -= count;
2046
+ item.locations = item.locations.filter((loc) => loc.file !== filePath);
2047
+ }
2048
+ }
2049
+ for (const [name, count] of oldEntry.components) {
2050
+ const item = stats.components.find((c) => c.name === name);
2051
+ if (item) {
2052
+ item.count -= count;
2053
+ item.locations = item.locations.filter((loc) => loc.file !== filePath);
2054
+ }
2055
+ }
2056
+ fileHits.delete(filePath);
2057
+ _cache.timestamp = Date.now();
2058
+ }
1819
2059
  /**
1820
2060
  * 将 PascalCase/CamelCase 名称转换为 kebab-case(用于搜索匹配)
1821
2061
  */
@@ -1827,10 +2067,8 @@ function toKebabCase(name) {
1827
2067
  * 返回项目中实际使用的 functions 和 components(仅 count > 0)
1828
2068
  */
1829
2069
  async function getOverview(projectRoot) {
1830
- const statsPromise = scanUsageStats(projectRoot, functions, components);
1831
- const apiListPromise = getRequestList(projectRoot);
1832
- const [stats, apiList] = await Promise.all([statsPromise, apiListPromise]);
1833
- const apiMethodCount = apiList.modules.reduce((total, module) => {
2070
+ const stats = (await getOrCreateCache(projectRoot)).stats;
2071
+ const apiMethodCount = (await getRequestList(projectRoot)).modules.reduce((total, module) => {
1834
2072
  return total + module.functions.length;
1835
2073
  }, 0);
1836
2074
  const moduleCount = countRegisteredModules(projectRoot);
@@ -1848,13 +2086,13 @@ async function getOverview(projectRoot) {
1848
2086
  * 返回所有追踪项的使用情况(包括未使用的 count = 0)
1849
2087
  */
1850
2088
  async function getUsageStats(projectRoot) {
1851
- return await scanUsageStats(projectRoot, functions, components);
2089
+ return (await getOrCreateCache(projectRoot)).stats;
1852
2090
  }
1853
2091
  /**
1854
2092
  * 获取组件使用详情,附带统计摘要
1855
2093
  */
1856
2094
  async function getComponentUsage(projectRoot) {
1857
- const componentsWithMeta = (await getUsageStats(projectRoot)).components.map((component) => {
2095
+ const componentsWithMeta = (await getOrCreateCache(projectRoot)).stats.components.map((component) => {
1858
2096
  const dependents = Array.from(new Set(component.locations.map((location) => location.file))).sort();
1859
2097
  const componentFilePath = component.locations[0]?.file;
1860
2098
  return {
@@ -1880,7 +2118,7 @@ async function getComponentUsage(projectRoot) {
1880
2118
  * 获取 Import/Hook 使用详情,附带统计摘要
1881
2119
  */
1882
2120
  async function getImportUsage(projectRoot) {
1883
- const importsWithMeta = (await getUsageStats(projectRoot)).functions.map((importItem) => {
2121
+ const importsWithMeta = (await getOrCreateCache(projectRoot)).stats.functions.map((importItem) => {
1884
2122
  const dependents = Array.from(new Set(importItem.locations.map((location) => location.file))).sort();
1885
2123
  const importFilePath = importItem.locations[0]?.file;
1886
2124
  return {
@@ -1997,20 +2235,51 @@ async function getPackageVersion(root, packageName) {
1997
2235
  * 能力概览:
1998
2236
  * - 提供一个轻量 UI(iframe)静态资源服务:`/__pubinfo_devtools`。
1999
2237
  * - 提供 devtools 专用的 API:`/__pubinfo_devtools_api`。
2000
- * - `/`返回项目文件数量(用于示例与健康检查)。
2238
+ * - `/overview`返回项目中实际使用的方法和组件。
2239
+ * - `/usage-stats` → 返回完整使用统计。
2240
+ * - `/component-usage` → 返回组件使用详情。
2241
+ * - `/import-usage` → 返回 Import/Hook 使用详情。
2001
2242
  * - `/api-list` → 返回解析的 OpenAPI 配置与扫描到的 API 函数/类型清单。
2243
+ * - `/package-version` → 返回包版本信息。
2244
+ *
2245
+ * 性能优化:
2246
+ * - 首次请求时执行全量扫描并缓存结果。
2247
+ * - 借助 Vite watcher 监听文件变化,仅对变更文件做增量更新。
2248
+ * - 所有 API 共享同一份缓存,避免重复扫描。
2249
+ * - JSON 响应不做美化格式化(减少 ~30% 体积)。
2002
2250
  *
2003
2251
  * 注意:本插件默认以 `enforce: 'pre'` 注入,避免被其他中间件拦截。
2004
2252
  * 路由均在开发服务器中间件阶段生效,不影响生产构建输出。
2005
2253
  *
2006
2254
  * 安全说明:这些接口仅用于本地开发环境调试,不应暴露到公网。
2007
2255
  */
2256
+ /** 源码文件扩展名,与 ast-analyzer 保持一致 */
2257
+ const SOURCE_EXTENSIONS = new Set([
2258
+ ".ts",
2259
+ ".tsx",
2260
+ ".js",
2261
+ ".jsx",
2262
+ ".vue"
2263
+ ]);
2264
+ function isSourceFile(path) {
2265
+ const dot = path.lastIndexOf(".");
2266
+ return dot !== -1 && SOURCE_EXTENSIONS.has(path.slice(dot));
2267
+ }
2008
2268
  function pubinfoDevtools() {
2009
2269
  return {
2010
2270
  name: "vite-plugin-pubinfo-devtools",
2011
2271
  enforce: "pre",
2012
2272
  configureServer(server) {
2013
2273
  const baseURL = "__pubinfo_devtools";
2274
+ server.watcher.on("change", (filePath) => {
2275
+ if (isSourceFile(filePath)) incrementalUpdate(filePath).catch(() => {});
2276
+ });
2277
+ server.watcher.on("add", (filePath) => {
2278
+ if (isSourceFile(filePath)) incrementalUpdate(filePath).catch(() => {});
2279
+ });
2280
+ server.watcher.on("unlink", (filePath) => {
2281
+ if (isSourceFile(filePath)) incrementalRemove(filePath).catch(() => {});
2282
+ });
2014
2283
  server.middlewares.use(`/${baseURL}`, sirv(DIR_CLIENT, {
2015
2284
  single: true,
2016
2285
  dev: true
@@ -2019,82 +2288,42 @@ function pubinfoDevtools() {
2019
2288
  if (!req.url) return next();
2020
2289
  if (req.url === "/overview") {
2021
2290
  try {
2022
- const overview = await getOverview(server.config.root);
2023
- res.setHeader("Content-Type", "application/json");
2024
- res.write(JSON.stringify(overview, null, 2));
2291
+ sendJSON(res, await getOverview(server.config.root));
2025
2292
  } catch (error) {
2026
- res.statusCode = 500;
2027
- res.setHeader("Content-Type", "application/json");
2028
- res.write(JSON.stringify({
2029
- error: "Failed to get overview data",
2030
- message: error instanceof Error ? error.message : String(error)
2031
- }));
2293
+ sendError(res, "Failed to get overview data", error);
2032
2294
  }
2033
- res.end();
2034
2295
  return;
2035
2296
  }
2036
2297
  if (req.url === "/usage-stats") {
2037
2298
  try {
2038
- const usageStats = await getUsageStats(server.config.root);
2039
- res.setHeader("Content-Type", "application/json");
2040
- res.write(JSON.stringify(usageStats, null, 2));
2299
+ sendJSON(res, await getUsageStats(server.config.root));
2041
2300
  } catch (error) {
2042
- res.statusCode = 500;
2043
- res.setHeader("Content-Type", "application/json");
2044
- res.write(JSON.stringify({
2045
- error: "Failed to get usage statistics",
2046
- message: error instanceof Error ? error.message : String(error)
2047
- }));
2301
+ sendError(res, "Failed to get usage statistics", error);
2048
2302
  }
2049
- res.end();
2050
2303
  return;
2051
2304
  }
2052
2305
  if (req.url === "/component-usage") {
2053
2306
  try {
2054
- const componentUsage = await getComponentUsage(server.config.root);
2055
- res.setHeader("Content-Type", "application/json");
2056
- res.write(JSON.stringify(componentUsage, null, 2));
2307
+ sendJSON(res, await getComponentUsage(server.config.root));
2057
2308
  } catch (error) {
2058
- res.statusCode = 500;
2059
- res.setHeader("Content-Type", "application/json");
2060
- res.write(JSON.stringify({
2061
- error: "Failed to get component usage",
2062
- message: error instanceof Error ? error.message : String(error)
2063
- }));
2309
+ sendError(res, "Failed to get component usage", error);
2064
2310
  }
2065
- res.end();
2066
2311
  return;
2067
2312
  }
2068
2313
  if (req.url === "/import-usage") {
2069
2314
  try {
2070
- const importUsage = await getImportUsage(server.config.root);
2071
- res.setHeader("Content-Type", "application/json");
2072
- res.write(JSON.stringify(importUsage, null, 2));
2315
+ sendJSON(res, await getImportUsage(server.config.root));
2073
2316
  } catch (error) {
2074
- res.statusCode = 500;
2075
- res.setHeader("Content-Type", "application/json");
2076
- res.write(JSON.stringify({
2077
- error: "Failed to get import usage",
2078
- message: error instanceof Error ? error.message : String(error)
2079
- }));
2317
+ sendError(res, "Failed to get import usage", error);
2080
2318
  }
2081
- res.end();
2082
2319
  return;
2083
2320
  }
2084
2321
  if (req.url.startsWith("/api-list")) {
2085
2322
  try {
2086
- const apiList = await getRequestList(server.config.root);
2087
- res.setHeader("Content-Type", "application/json");
2088
- res.write(JSON.stringify(apiList, null, 2));
2323
+ sendJSON(res, await getRequestList(server.config.root));
2089
2324
  } catch (error) {
2090
- res.statusCode = 500;
2091
- res.setHeader("Content-Type", "application/json");
2092
- res.write(JSON.stringify({
2093
- error: "Failed to get API list",
2094
- message: error instanceof Error ? error.message : String(error)
2095
- }));
2325
+ sendError(res, "Failed to get API list", error);
2096
2326
  }
2097
- res.end();
2098
2327
  return;
2099
2328
  }
2100
2329
  if (req.url.startsWith("/package-version")) {
@@ -2102,26 +2331,16 @@ function pubinfoDevtools() {
2102
2331
  const packageName = new URLSearchParams(req.url.slice(16)).get("package") || "";
2103
2332
  if (!packageName) {
2104
2333
  res.statusCode = 400;
2105
- res.setHeader("Content-Type", "application/json");
2106
- res.write(JSON.stringify({
2334
+ sendJSON(res, {
2107
2335
  error: "Missing package parameter",
2108
2336
  message: "Please provide a package name using ?package=<package-name>"
2109
- }));
2110
- res.end();
2337
+ });
2111
2338
  return;
2112
2339
  }
2113
- const versionInfo = await getPackageVersion(server.config.root, packageName);
2114
- res.setHeader("Content-Type", "application/json");
2115
- res.write(JSON.stringify(versionInfo, null, 2));
2340
+ sendJSON(res, await getPackageVersion(server.config.root, packageName));
2116
2341
  } catch (error) {
2117
- res.statusCode = 500;
2118
- res.setHeader("Content-Type", "application/json");
2119
- res.write(JSON.stringify({
2120
- error: "Failed to get package version",
2121
- message: error instanceof Error ? error.message : String(error)
2122
- }));
2342
+ sendError(res, "Failed to get package version", error);
2123
2343
  }
2124
- res.end();
2125
2344
  return;
2126
2345
  }
2127
2346
  next();
@@ -2129,6 +2348,24 @@ function pubinfoDevtools() {
2129
2348
  }
2130
2349
  };
2131
2350
  }
2351
+ /**
2352
+ * 发送 JSON 响应(不做美化,减小体积)
2353
+ */
2354
+ function sendJSON(res, data) {
2355
+ res.setHeader("Content-Type", "application/json");
2356
+ res.end(JSON.stringify(data));
2357
+ }
2358
+ /**
2359
+ * 发送 500 错误响应
2360
+ */
2361
+ function sendError(res, defaultMessage, error) {
2362
+ res.statusCode = 500;
2363
+ res.setHeader("Content-Type", "application/json");
2364
+ res.end(JSON.stringify({
2365
+ error: defaultMessage,
2366
+ message: error instanceof Error ? error.message : String(error)
2367
+ }));
2368
+ }
2132
2369
 
2133
2370
  //#endregion
2134
2371
  export { pubinfoDevtools };