@pubinfo-pr/devtools 0.171.4 → 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/client/assets/{Navbar-Dn_FwmwT.js → Navbar-B5ucbOfi.js} +2 -2
- package/dist/client/assets/{PanelGrids-Dr3Hj3fq.js → PanelGrids-DBWGBtRY.js} +2 -2
- package/dist/client/assets/{SelectTabs-DGbhp-SJ.js → SelectTabs-DQNzvqnY.js} +2 -2
- package/dist/client/assets/{component-BYcFX5o9.js → component-BZQrXc47.js} +4 -4
- package/dist/client/assets/{fetch-X85SI-zj.js → fetch-DghUgo4Q.js} +1 -1
- package/dist/client/assets/{import-8bGFSBml.js → import-BaLJFHqb.js} +4 -4
- package/dist/client/assets/{index-C1hERR5y.css → index-BMmz63mm.css} +1 -1
- package/dist/client/assets/{index-C74Jw2V-.js → index-OmlSRsjt.js} +5 -5
- package/dist/client/assets/{pages-Dj7OCD93.js → pages-DS7RvZuv.js} +4 -4
- package/dist/client/assets/{server-router-Byd0xUzV.js → server-router-BK_W_u7D.js} +4 -4
- package/dist/client/index.html +2 -2
- package/dist/index.d.mts +0 -15
- package/dist/index.mjs +425 -188
- package/package.json +1 -1
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 {
|
|
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
|
|
1504
|
-
if (
|
|
1505
|
-
if (!
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
|
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
|
|
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
|
|
1535
|
-
case ".js": return
|
|
1566
|
+
case ".jsx": return Lang.Tsx;
|
|
1567
|
+
case ".js": return Lang.JavaScript;
|
|
1536
1568
|
case ".ts":
|
|
1537
1569
|
case ".vue":
|
|
1538
|
-
default: return
|
|
1570
|
+
default: return Lang.TypeScript;
|
|
1539
1571
|
}
|
|
1540
1572
|
}
|
|
1541
1573
|
/**
|
|
1542
|
-
*
|
|
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
|
|
1545
|
-
const
|
|
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
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
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 -
|
|
1666
|
+
column: pos - lineOffsets[lineNum - 1] + 1,
|
|
1580
1667
|
code: lines[lineNum - 1].trim().slice(0, 100)
|
|
1581
|
-
}
|
|
1582
|
-
|
|
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
|
|
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
|
|
1591
|
-
const
|
|
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
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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 =
|
|
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
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
const stats = functionStatsMap.get(
|
|
1663
|
-
stats
|
|
1664
|
-
|
|
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
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
|
1831
|
-
const
|
|
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
|
|
2089
|
+
return (await getOrCreateCache(projectRoot)).stats;
|
|
1852
2090
|
}
|
|
1853
2091
|
/**
|
|
1854
2092
|
* 获取组件使用详情,附带统计摘要
|
|
1855
2093
|
*/
|
|
1856
2094
|
async function getComponentUsage(projectRoot) {
|
|
1857
|
-
const componentsWithMeta = (await
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 };
|