@agile-team/wl-skills-kit 2.9.4 → 2.10.1

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.
@@ -0,0 +1,769 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/ast-rules.js — wl-skills-kit AST 级规范检测引擎
5
+ *
6
+ * 补充 validate(正则)无法覆盖的语义级规则。
7
+ * 依赖:@vue/compiler-sfc(解析 .vue)、@babel/parser(解析 <script> AST)
8
+ * 这两个包在 Vue 3 + Vite 项目中天然存在,用 try-require 做优雅降级。
9
+ *
10
+ * 规则编号 R1~R12 对应 standards 02/06/07/10/12/13 中的语义约束:
11
+ * R1: index.vue <script setup> 业务逻辑行数超阈值 → warn(02)
12
+ * R2: index.vue 含禁止 import(getAction/postAction/sessionStorage 等)→ error(02/06)
13
+ * R3: 页面用 <el-table> 但未用 <BaseTable> → error(12/13)
14
+ * R4: cid 全局重复 → error(12)
15
+ * R5: 纯列表型页面 data.ts 未用 AbstractPageQueryHook → warn(02)
16
+ * R6: index.vue 或 data.ts 直接 import axios → error(06)
17
+ * R7: index.vue 或 data.ts 用 eval / new Function → error(06)
18
+ * R8: 强制 3 文件分离 — 有 API 调用但无 data.ts,或逻辑泄漏 → error/warn(02)
19
+ * R9: api.md 质量 — URL 与 data.ts API_CONFIG 不一致 → warn(02)
20
+ * R10: 平台组件替换检测 — el-form/el-select/el-date-picker 等应替换为平台封装 → error(13)
21
+ * R11: data.ts 禁止 import Pinia Store → error(10)
22
+ * R12: 硬编码 IP/URL 检测 → error/warn(07)
23
+ *
24
+ * 导出函数:
25
+ * runAstRules(targetDir, scanRel, { stagedFiles }) → { issues, pages }
26
+ * parseVueScript(absPath) → { content, template, source } | null
27
+ * countEffectiveLines(scriptContent) → number
28
+ * hasAstAvailable() → boolean
29
+ */
30
+
31
+ const fs = require("fs");
32
+ const path = require("path");
33
+ const { execFileSync } = require("child_process");
34
+
35
+ // ─── AST 依赖探测(优雅降级)──────────────────────────────────────────
36
+ //
37
+ // 安全策略:require @vue/compiler-sfc 在某些环境可能很慢或卡死
38
+ // 用 try/catch 包裹,失败时静默降级为正则模式,不阻断 validate 流程
39
+
40
+ let _compilerSfc = null;
41
+ let _babelParser = null;
42
+ let _astChecked = false;
43
+
44
+ function ensureAst() {
45
+ if (_astChecked) return _compilerSfc && _babelParser;
46
+ _astChecked = true;
47
+
48
+ // 优先从 kit 自身 node_modules 解析(开发/测试环境)
49
+ const tryPaths = [
50
+ // 1. kit 自身 node_modules(标准 require 解析)
51
+ null,
52
+ // 2. 调用方项目(CWD)的 node_modules — 业务项目通过 npx/node 运行时
53
+ // kit 本身没装 @vue/compiler-sfc,但业务项目有
54
+ ];
55
+
56
+ // 尝试标准 require(从 kit 目录解析)
57
+ try {
58
+ _compilerSfc = require("@vue/compiler-sfc");
59
+ } catch {
60
+ _compilerSfc = null;
61
+ }
62
+ try {
63
+ _babelParser = require("@babel/parser");
64
+ } catch {
65
+ _babelParser = null;
66
+ }
67
+
68
+ // 如果标准 require 失败,尝试从 CWD(业务项目根目录)解析
69
+ if (!_compilerSfc || !_babelParser) {
70
+ const cwd = process.env.WL_PROJECT_ROOT || process.cwd();
71
+ try {
72
+ const createRequire = require("module").createRequire;
73
+ const cwdRequire = createRequire(cwd + "/package.json");
74
+ if (!_compilerSfc) {
75
+ try { _compilerSfc = cwdRequire("@vue/compiler-sfc"); } catch {}
76
+ }
77
+ if (!_babelParser) {
78
+ try { _babelParser = cwdRequire("@babel/parser"); } catch {}
79
+ }
80
+ } catch {
81
+ // createRequire 不可用(极旧 Node),忽略
82
+ }
83
+ }
84
+
85
+ return _compilerSfc && _babelParser;
86
+ }
87
+
88
+ /**
89
+ * 验证 @vue/compiler-sfc 的 API 是否兼容(parse 函数存在且返回 descriptor)
90
+ * 防止 v2.x 等不兼容版本加载成功但实际无法工作
91
+ */
92
+ function isAstFunctionallyUsable() {
93
+ if (!ensureAst()) return false;
94
+ try {
95
+ const testResult = _compilerSfc.parse("<template><div/></template>", {
96
+ filename: "test.vue",
97
+ });
98
+ return Boolean(testResult && testResult.descriptor);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function hasAstAvailable() {
105
+ return Boolean(ensureAst());
106
+ }
107
+
108
+ /**
109
+ * 去除注释和字符串字面量,只保留代码逻辑文本
110
+ * 用于正则匹配时避免注释/字符串中的关键字产生误报
111
+ */
112
+ function stripCommentsAndStrings(code) {
113
+ if (!code) return "";
114
+ // 去除块注释
115
+ let result = code.replace(/\/\*[\s\S]*?\*\//g, "");
116
+ // 去除行注释
117
+ result = result.replace(/\/\/[^\n]*/g, "");
118
+ // 去除模板字符串(反引号包围的内容可能含关键字)
119
+ result = result.replace(/`[^`]*`/g, '""');
120
+ // 去除单/双引号字符串内容(保留引号本身以维持结构)
121
+ result = result.replace(/"(?:[^"\\]|\\.)*"/g, '""');
122
+ result = result.replace(/'(?:[^'\\]|\\.)*'/g, "''");
123
+ return result;
124
+ }
125
+
126
+ // ─── 配置 ──────────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * 豁免标记检测
130
+ *
131
+ * 在文件中通过特殊注释标记豁免某条规则:
132
+ * <!-- wl-skills:ignore R3 --> ← 在 index.vue 模板中
133
+ * // wl-skills:ignore R3 ← 在 script 中
134
+ * /* wl-skills:ignore R3 * / ← 在 scss/script 中
135
+ *
136
+ * 用于特殊场景(如弹窗内确实需要 el-table、确实需要在 index.vue 中用 sessionStorage)。
137
+ * 标记必须带规则编号(R1~R7),精确豁免,不是全局豁免。
138
+ */
139
+ function hasIgnoreMarker(content, rule) {
140
+ if (!content || !rule) return false;
141
+ const patterns = [
142
+ new RegExp("wl-skills:ignore\\s+" + rule + "\\b", "i"),
143
+ new RegExp("wl-skills:\\s*ignore\\s+" + rule + "\\b", "i"),
144
+ ];
145
+ return patterns.some((p) => p.test(content));
146
+ }
147
+
148
+ const CONFIG = {
149
+ // R1: 纯列表页 index.vue 阈值(index.vue 应几乎只有模板+解构)
150
+ SCRIPT_LINE_THRESHOLD_LIST: 40,
151
+ // R1: 非列表页(表单/详情/设计器)允许更多逻辑
152
+ SCRIPT_LINE_THRESHOLD_OTHER: 120,
153
+ FORBIDDEN_IMPORTS: [
154
+ "getAction",
155
+ "postAction",
156
+ "putAction",
157
+ "deleteAction",
158
+ "actionBatch",
159
+ ],
160
+ FORBIDDEN_GLOBALS: ["sessionStorage", "localStorage"],
161
+ WARN_IMPORTS: ["useRoute"],
162
+ SKIP_DIRS: ["node_modules", "dist", ".git", "demo", "template"],
163
+ };
164
+
165
+ // ─── 工具函数 ──────────────────────────────────────────────────────────
166
+
167
+ function walkDir(dir, base, results) {
168
+ results = results || [];
169
+ if (!fs.existsSync(dir)) return results;
170
+ const entries = fs.readdirSync(dir);
171
+ for (const entry of entries) {
172
+ const full = path.join(dir, entry);
173
+ const stat = fs.statSync(full);
174
+ if (stat.isDirectory()) {
175
+ if (CONFIG.SKIP_DIRS.includes(entry)) continue;
176
+ walkDir(full, base, results);
177
+ } else {
178
+ results.push({
179
+ abs: full,
180
+ rel: path.relative(base, full).replace(/\\/g, "/"),
181
+ });
182
+ }
183
+ }
184
+ return results;
185
+ }
186
+
187
+ /**
188
+ * 解析 .vue 文件,提取 <script setup> 内容和模板
189
+ */
190
+ function parseVueScript(absPath) {
191
+ if (!ensureAst() || !fs.existsSync(absPath)) return null;
192
+ const source = fs.readFileSync(absPath, "utf-8");
193
+ let descriptor;
194
+ try {
195
+ const result = _compilerSfc.parse(source, { filename: absPath });
196
+ descriptor = result.descriptor;
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!descriptor) return null;
201
+ const scriptContent =
202
+ descriptor.scriptSetup && descriptor.scriptSetup.content
203
+ ? descriptor.scriptSetup.content
204
+ : descriptor.script && descriptor.script.content
205
+ ? descriptor.script.content
206
+ : "";
207
+ const template =
208
+ descriptor.template && descriptor.template.content
209
+ ? descriptor.template.content
210
+ : "";
211
+ return { content: scriptContent, template, source };
212
+ }
213
+
214
+ /**
215
+ * 统计有效代码行数(排除 import、空行、注释)
216
+ */
217
+ function countEffectiveLines(scriptContent) {
218
+ if (!scriptContent) return 0;
219
+ const lines = scriptContent.split("\n");
220
+ let count = 0;
221
+ let inBlock = false;
222
+ for (const raw of lines) {
223
+ const line = raw.trim();
224
+ if (line === "") continue;
225
+ if (inBlock) {
226
+ if (line.includes("*/")) inBlock = false;
227
+ continue;
228
+ }
229
+ if (line.startsWith("/*")) {
230
+ if (!line.includes("*/")) inBlock = true;
231
+ continue;
232
+ }
233
+ if (line.startsWith("//")) continue;
234
+ if (line.startsWith("import ")) continue;
235
+ count++;
236
+ }
237
+ return count;
238
+ }
239
+
240
+ /**
241
+ * 用 babel/parser 解析 script,提取 import 标识符和来源
242
+ */
243
+ function extractScriptInfo(scriptContent) {
244
+ if (!ensureAst() || !scriptContent) {
245
+ return { specifiers: [], sources: [] };
246
+ }
247
+ let ast;
248
+ try {
249
+ ast = _babelParser.parse(scriptContent, {
250
+ sourceType: "module",
251
+ plugins: ["typescript", "jsx"],
252
+ errorRecovery: true,
253
+ });
254
+ } catch {
255
+ return { specifiers: [], sources: [] };
256
+ }
257
+ const specifiers = [];
258
+ const sources = [];
259
+ for (const node of ast.program.body) {
260
+ if (node.type !== "ImportDeclaration") continue;
261
+ sources.push(node.source.value);
262
+ for (const spec of node.specifiers) {
263
+ if (spec.type === "ImportSpecifier") {
264
+ specifiers.push(spec.imported.name || spec.local.name);
265
+ } else if (spec.type === "ImportDefaultSpecifier") {
266
+ specifiers.push(spec.local.name);
267
+ } else if (spec.type === "ImportNamespaceSpecifier") {
268
+ specifiers.push(spec.local.name);
269
+ }
270
+ }
271
+ }
272
+ return { specifiers, sources };
273
+ }
274
+
275
+ /**
276
+ * 检测模板中是否有 el-table(非 BaseTable)
277
+ */
278
+ function hasRawElTable(template) {
279
+ return /<el-table[\s>]/.test(template || "");
280
+ }
281
+ function hasBaseTable(template) {
282
+ return /<BaseTable[\s>]/.test(template || "");
283
+ }
284
+
285
+ /**
286
+ * 从文件中提取 cid 实际值(字符串字面量,排除 Vue 动态绑定)
287
+ */
288
+ function extractCidsFromContent(content) {
289
+ const cids = [];
290
+ for (const m of content.matchAll(/TABLE_CID\s*=\s*["']([^"']+)["']/g)) {
291
+ cids.push(m[1]);
292
+ }
293
+ for (const m of content.matchAll(/(?<![:\w])cid\s*=\s*["']([^"']+)["']/g)) {
294
+ cids.push(m[1]);
295
+ }
296
+ return cids;
297
+ }
298
+
299
+ /**
300
+ * 判断页面是否为列表型(有 BaseTable 或 el-table + 分页)
301
+ */
302
+ function isListTypePage(template) {
303
+ if (/<BaseTable|<el-table/.test(template || "")) return true;
304
+ if (/jh-pagination/.test(template || "")) return true;
305
+ return false;
306
+ }
307
+
308
+ /**
309
+ * 更精确地判断是否为"纯列表型"页面(LIST/MASTER_DETAIL/TREE_LIST)
310
+ * 这些页面必须使用 AbstractPageQueryHook,且 index.vue 应几乎只有模板
311
+ *
312
+ * 判据(同时满足才算纯列表):
313
+ * - 主区域有 BaseTable(非弹窗内的小表格)
314
+ * - 有 jh-pagination 分页
315
+ * - 没有 el-form/el-tabs(表单/Tab 页面不是纯列表)
316
+ *
317
+ * DETAIL_TABS/FORM_ROUTE/CHANGE_HISTORY 页面虽然可能含 BaseTable 子表,
318
+ * 但不满足以上全部条件,因此不会被判为"纯列表型"。
319
+ */
320
+ function isLikelyListPage(template) {
321
+ const t = template || "";
322
+ const hasBaseTable = /<BaseTable[\s>]/.test(t);
323
+ const hasPagination = /jh-pagination/.test(t);
324
+ const hasForm = /<el-form[\s>]/.test(t);
325
+ const hasTabs = /<el-tabs[\s>]/.test(t);
326
+ // 纯列表 = 有表格 + 有分页 + 无表单 + 无 Tab
327
+ return hasBaseTable && hasPagination && !hasForm && !hasTabs;
328
+ }
329
+
330
+ // ─── 主检测函数 ────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * @param {string} targetDir 项目根目录绝对路径
334
+ * @param {string} scanRel 扫描相对路径(默认 src/views)
335
+ * @param {object} options { stagedFiles?: string[] } 限制只检测 staged 文件
336
+ * @returns { issues: Array<{level,dir,text,rule}>, pages: number }
337
+ */
338
+ function runAstRules(targetDir, scanRel, options) {
339
+ options = options || {};
340
+ const stagedFilter = options.stagedFiles
341
+ ? new Set(options.stagedFiles.map((f) => f.replace(/\\/g, "/")))
342
+ : null;
343
+ const scanDir = path.join(targetDir, scanRel || "src/views");
344
+
345
+ const astOk = isAstFunctionallyUsable();
346
+ if (!astOk) {
347
+ return {
348
+ issues: [
349
+ {
350
+ level: "warn",
351
+ dir: scanRel || "src/views",
352
+ text: "AST 引擎不可用(@vue/compiler-sfc 未安装或版本不兼容),跳过语义级规则检测。建议 pnpm install 后重试。",
353
+ rule: "AST",
354
+ },
355
+ ],
356
+ pages: 0,
357
+ astAvailable: false,
358
+ };
359
+ }
360
+
361
+ // 收集页面目录
362
+ const allFiles = walkDir(scanDir, targetDir);
363
+ const dirMap = new Map();
364
+ for (const f of allFiles) {
365
+ const dir = path.dirname(f.rel);
366
+ if (!dirMap.has(dir)) dirMap.set(dir, new Set());
367
+ dirMap.get(dir).add(f.rel.split("/").pop());
368
+ }
369
+ const pages = [];
370
+ for (const [dir, names] of dirMap.entries()) {
371
+ if (!names.has("index.vue")) continue;
372
+ if (
373
+ CONFIG.SKIP_DIRS.some(
374
+ (s) => dir.includes("/" + s + "/") || dir.startsWith(s + "/"),
375
+ )
376
+ )
377
+ continue;
378
+ pages.push({ dir, names });
379
+ }
380
+ pages.sort((a, b) => a.dir.localeCompare(b.dir));
381
+
382
+ const issues = [];
383
+ const globalCidMap = new Map(); // cid → Set<pageDir>
384
+
385
+ for (const page of pages) {
386
+ const absDir = path.join(targetDir, page.dir);
387
+
388
+ // 如果有 staged 过滤,只检测包含 staged 文件的目录
389
+ if (stagedFilter) {
390
+ const hasStaged = Array.from(stagedFilter).some((f) =>
391
+ f.startsWith(page.dir + "/") || f === page.dir + "/index.vue",
392
+ );
393
+ if (!hasStaged) continue;
394
+ }
395
+
396
+ const vuePath = path.join(absDir, "index.vue");
397
+ const dataPath = path.join(absDir, "data.ts");
398
+
399
+ const parsed = parseVueScript(vuePath);
400
+ if (!parsed) continue;
401
+ const { content: scriptContent, template, source: fullSource } = parsed;
402
+
403
+ // R1: script setup 业务逻辑行数(根据页面类型使用不同阈值)
404
+ const effectiveLines = countEffectiveLines(scriptContent);
405
+ const isList = isLikelyListPage(template);
406
+ const threshold = isList
407
+ ? CONFIG.SCRIPT_LINE_THRESHOLD_LIST
408
+ : CONFIG.SCRIPT_LINE_THRESHOLD_OTHER;
409
+ if (effectiveLines > threshold) {
410
+ issues.push({
411
+ level: "warn",
412
+ dir: page.dir,
413
+ text:
414
+ "index.vue <script> 业务逻辑 " +
415
+ effectiveLines +
416
+ " 行(" + (isList ? "列表页" : "非列表页") +
417
+ " 阈值 " + threshold + "),应迁移至 data.ts",
418
+ rule: "R1",
419
+ });
420
+ }
421
+
422
+ // R2: 禁止的 import / 全局 API
423
+ if (scriptContent) {
424
+ const { specifiers, sources } = extractScriptInfo(scriptContent);
425
+
426
+ for (const name of CONFIG.FORBIDDEN_IMPORTS) {
427
+ if (
428
+ (specifiers.includes(name) ||
429
+ new RegExp("\\b" + name + "\\s*\\(").test(scriptContent)) &&
430
+ !hasIgnoreMarker(fullSource, "R2")
431
+ ) {
432
+ issues.push({
433
+ level: "error",
434
+ dir: page.dir,
435
+ text:
436
+ "index.vue 中禁止使用 " +
437
+ name +
438
+ "(应在 data.ts 中调用)",
439
+ rule: "R2",
440
+ });
441
+ }
442
+ }
443
+
444
+ for (const name of CONFIG.FORBIDDEN_GLOBALS) {
445
+ // 在去除注释/字符串后的代码中检测,避免误报
446
+ const codeOnly = stripCommentsAndStrings(scriptContent);
447
+ if (
448
+ new RegExp("\\b" + name + "\\b").test(codeOnly) &&
449
+ !hasIgnoreMarker(fullSource, "R2")
450
+ ) {
451
+ issues.push({
452
+ level: "error",
453
+ dir: page.dir,
454
+ text:
455
+ "index.vue 中禁止直接使用 " +
456
+ name +
457
+ "(应在 data.ts 中处理)",
458
+ rule: "R2",
459
+ });
460
+ }
461
+ }
462
+
463
+ for (const name of CONFIG.WARN_IMPORTS) {
464
+ if (specifiers.includes(name)) {
465
+ issues.push({
466
+ level: "warn",
467
+ dir: page.dir,
468
+ text:
469
+ "index.vue 中使用了 " +
470
+ name +
471
+ "(读取路由参数应在 data.ts 中处理)",
472
+ rule: "R2",
473
+ });
474
+ }
475
+ }
476
+
477
+ // R6: 直接 import axios
478
+ if (sources.some((s) => s === "axios")) {
479
+ issues.push({
480
+ level: "error",
481
+ dir: page.dir,
482
+ text: "index.vue 中禁止直接 import axios(使用 getAction/postAction)",
483
+ rule: "R6",
484
+ });
485
+ }
486
+ }
487
+
488
+ // R3: el-table 但未用 BaseTable(检查豁免标记)
489
+ if (
490
+ hasRawElTable(template) &&
491
+ !hasBaseTable(template) &&
492
+ !hasIgnoreMarker(fullSource, "R3")
493
+ ) {
494
+ issues.push({
495
+ level: "error",
496
+ dir: page.dir,
497
+ text: "页面使用 <el-table> 但未使用 <BaseTable>(应使用平台组件)",
498
+ rule: "R3",
499
+ });
500
+ }
501
+
502
+ // R4: cid 收集(全局去重)— 每个页面只记一次
503
+ const pageCids = new Set();
504
+ for (const fname of ["index.vue", "data.ts"]) {
505
+ const fpath = path.join(absDir, fname);
506
+ if (!fs.existsSync(fpath)) continue;
507
+ const content = fs.readFileSync(fpath, "utf-8");
508
+ for (const cid of extractCidsFromContent(content)) {
509
+ pageCids.add(cid);
510
+ }
511
+ }
512
+ for (const cid of pageCids) {
513
+ if (!globalCidMap.has(cid)) globalCidMap.set(cid, new Set());
514
+ globalCidMap.get(cid).add(page.dir);
515
+ }
516
+
517
+ // R5: 纯列表型页面未用 AbstractPageQueryHook
518
+ // 注意:只用 isLikelyListPage(精确判断),不用 isListTypePage(误报非列表页)
519
+ if (page.names.has("data.ts") && isLikelyListPage(template)) {
520
+ const dc = fs.existsSync(dataPath)
521
+ ? fs.readFileSync(dataPath, "utf-8")
522
+ : "";
523
+ // 空 data.ts 或无 AbstractPageQueryHook 都需要警告
524
+ if (!dc.trim() || !/AbstractPageQueryHook/.test(dc)) {
525
+ issues.push({
526
+ level: "warn",
527
+ dir: page.dir,
528
+ text: "列表型页面 data.ts 未使用 AbstractPageQueryHook(确认是否有充分理由)",
529
+ rule: "R5",
530
+ });
531
+ }
532
+ }
533
+
534
+ // R6/R7: data.ts 检查 axios / eval / new Function
535
+ if (fs.existsSync(dataPath)) {
536
+ const dc = fs.readFileSync(dataPath, "utf-8");
537
+ const dcClean = stripCommentsAndStrings(dc);
538
+ // axios: AST import source 或 require 调用
539
+ const { sources: dcSources } = extractScriptInfo(dc);
540
+ if (
541
+ dcSources.some((s) => s === "axios") ||
542
+ /require\s*\(\s*["']axios["']\s*\)/.test(dcClean)
543
+ ) {
544
+ issues.push({
545
+ level: "error",
546
+ dir: page.dir,
547
+ text: "data.ts 中禁止直接 import axios(使用 getAction/postAction)",
548
+ rule: "R6",
549
+ });
550
+ }
551
+ if (/\beval\s*\(/.test(dcClean) || /new\s+Function\s*\(/.test(dcClean)) {
552
+ issues.push({
553
+ level: "error",
554
+ dir: page.dir,
555
+ text: "data.ts 中禁止使用 eval / new Function(安全风险)",
556
+ rule: "R7",
557
+ });
558
+ }
559
+ }
560
+
561
+ // R7: index.vue 检查 eval / new Function
562
+ if (scriptContent) {
563
+ const scClean = stripCommentsAndStrings(scriptContent);
564
+ if (/\beval\s*\(/.test(scClean) || /new\s+Function\s*\(/.test(scClean)) {
565
+ issues.push({
566
+ level: "error",
567
+ dir: page.dir,
568
+ text: "index.vue 中禁止使用 eval / new Function(安全风险)",
569
+ rule: "R7",
570
+ });
571
+ }
572
+ }
573
+
574
+ // R8: 强制 3 文件分离 — 有 API 调用/大量逻辑但无 data.ts
575
+ // 任何页面(不分类型)只要有接口调用或超过阈值行数,就应该拆出 data.ts
576
+ {
577
+ const hasApiCall = /getAction|postAction|putAction|deleteAction|API_CONFIG/.test(
578
+ stripCommentsAndStrings(scriptContent),
579
+ );
580
+ const hasScss = page.names.has("index.scss");
581
+ if (!page.names.has("data.ts")) {
582
+ if (hasApiCall && !hasIgnoreMarker(fullSource, "R8")) {
583
+ issues.push({
584
+ level: "error",
585
+ dir: page.dir,
586
+ text: "页面有接口调用但缺 data.ts(业务逻辑必须在 data.ts 中)",
587
+ rule: "R8",
588
+ });
589
+ } else if (effectiveLines > 20 && !hasIgnoreMarker(fullSource, "R8")) {
590
+ issues.push({
591
+ level: "warn",
592
+ dir: page.dir,
593
+ text: "index.vue 有 " + effectiveLines + " 行逻辑但无 data.ts(建议拆分)",
594
+ rule: "R8",
595
+ });
596
+ }
597
+ }
598
+ // 有 data.ts 但 index.vue 仍然有 API 调用(逻辑泄漏)
599
+ if (page.names.has("data.ts") && hasApiCall && !hasIgnoreMarker(fullSource, "R8")) {
600
+ issues.push({
601
+ level: "error",
602
+ dir: page.dir,
603
+ text: "有 data.ts 但 index.vue 中仍含 API 调用(逻辑应全部在 data.ts)",
604
+ rule: "R8",
605
+ });
606
+ }
607
+ }
608
+
609
+ // R9: api.md 质量检测 — 有 API_CONFIG 时检查 api.md 结构完整性
610
+ if (page.names.has("data.ts")) {
611
+ const dc = fs.existsSync(dataPath)
612
+ ? fs.readFileSync(dataPath, "utf-8")
613
+ : "";
614
+ const hasApiConfig = /API_CONFIG/.test(dc);
615
+ if (hasApiConfig && page.names.has("api.md")) {
616
+ const apiMdPath = path.join(absDir, "api.md");
617
+ const apiMdContent = fs.readFileSync(apiMdPath, "utf-8");
618
+ // 检查 api.md 是否有接口列表表格(核心结构)
619
+ const hasInterfaceTable = /\|\s*操作\s*\|.*\|\s*Method\s*\||\|\s*操作\s*\|.*\|\s*URL\s*\|/.test(apiMdContent);
620
+ // 检查 api.md 是否有实体定义
621
+ const hasEntityDef = /字段|实体|Entity|字段名/.test(apiMdContent);
622
+ // 检查 api.md 中的 URL 是否与 data.ts API_CONFIG 一致
623
+ // 正则匹配 URL 路径:支持多段、数字、连字符、下划线
624
+ // 例:/mdata/mdataModel/list /api/v2/customer-archive/save /sys/user_role/list
625
+ const URL_REGEX = /\/[a-z][a-z0-9_-]*(?:\/[a-zA-Z0-9_-]+)+/g;
626
+ const apiMdUrls = Array.from(apiMdContent.matchAll(URL_REGEX)).map((m) => m[0]);
627
+ const dataTsUrls = Array.from(dc.matchAll(/["'](\/[a-z][a-z0-9_-]*(?:\/[a-zA-Z0-9_-]+)+)["']/g)).map((m) => m[1]);
628
+ // data.ts 中的 URL 必须在 api.md 中能找到完全匹配
629
+ const dataUrlsNotInApi = dataTsUrls.filter((u) => !apiMdUrls.includes(u));
630
+ if (dataUrlsNotInApi.length > 0 && !hasIgnoreMarker(apiMdContent, "R9")) {
631
+ issues.push({
632
+ level: "warn",
633
+ dir: page.dir,
634
+ text: "api.md 缺少接口定义:" + dataUrlsNotInApi.slice(0, 3).join(", ") + (dataUrlsNotInApi.length > 3 ? " 等" : ""),
635
+ rule: "R9",
636
+ });
637
+ }
638
+ }
639
+ }
640
+
641
+ // R10: 平台组件替换检测 — 业务页面禁止用 el-* 原生组件替代平台封装
642
+ // 对应 standard 13(🔴必遵+阻断),原覆盖率仅 16%,R10 补齐核心替换规则
643
+ // 豁免:组件内部(src/components/)允许使用 el-*;有 wl-skills:ignore R10 标记
644
+ {
645
+ const FORBIDDEN_NATIVE_COMPONENTS = [
646
+ { tag: "el-form", replace: "BaseQuery(查询区)或 c_formModal(弹窗表单)", min: "el-form" },
647
+ { tag: "el-pagination", replace: "jh-pagination", min: "el-pagination" },
648
+ { tag: "el-date-picker", replace: "jh-date / jh-date-range", min: "el-date-picker" },
649
+ { tag: "el-select", replace: "jh-select(dict 属性自动加载字典)", min: "el-select" },
650
+ { tag: "el-tree", replace: "C_Tree", min: "el-tree" },
651
+ { tag: "el-upload", replace: "jh-file-upload", min: "el-upload" },
652
+ ];
653
+ for (const { tag, replace } of FORBIDDEN_NATIVE_COMPONENTS) {
654
+ const tagRegex = new RegExp("<" + tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\s>]");
655
+ if (
656
+ tagRegex.test(template) &&
657
+ !hasIgnoreMarker(fullSource, "R10")
658
+ ) {
659
+ issues.push({
660
+ level: "error",
661
+ dir: page.dir,
662
+ text: "页面使用 <" + tag + "> 应替换为 " + replace + "(standard 13 平台组件合规)",
663
+ rule: "R10",
664
+ });
665
+ }
666
+ }
667
+ }
668
+
669
+ // R11: data.ts 禁止 import Store (Pinia) — 对应 standard 10.3
670
+ {
671
+ if (fs.existsSync(dataPath) && !hasIgnoreMarker(fullSource, "R11")) {
672
+ const dc = fs.readFileSync(dataPath, "utf-8");
673
+ const dcInfo = extractScriptInfo(dc);
674
+ // 检测 Pinia Store 导入
675
+ const hasStoreImport =
676
+ dcInfo.specifiers.some((s) => /Store$/.test(s)) ||
677
+ dcInfo.sources.some((s) => /pinia|stores?\//.test(s));
678
+ if (hasStoreImport) {
679
+ issues.push({
680
+ level: "error",
681
+ dir: page.dir,
682
+ text: "data.ts 中禁止 import Pinia Store(标准 10:Store 不应出现在页面逻辑层)",
683
+ rule: "R11",
684
+ });
685
+ }
686
+ }
687
+ }
688
+
689
+ // R12: 硬编码 IP / http:// 检测 — 对应 standard 07.4/07.5
690
+ {
691
+ const fullContent = scriptContent + (fs.existsSync(dataPath)
692
+ ? fs.readFileSync(dataPath, "utf-8")
693
+ : "");
694
+ if (fullContent && !hasIgnoreMarker(fullSource, "R12")) {
695
+ const cleaned = stripCommentsAndStrings(fullContent);
696
+ // 检测硬编码 IP 地址
697
+ const ipMatch = cleaned.match(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+\b/);
698
+ if (ipMatch) {
699
+ issues.push({
700
+ level: "error",
701
+ dir: page.dir,
702
+ text: "检测到硬编码 IP 地址 " + ipMatch[0] + "(standard 07:使用环境变量配置)",
703
+ rule: "R12",
704
+ });
705
+ }
706
+ // 检测硬编码 http:// 域名(排除 localhost 和注释)
707
+ const httpMatch = cleaned.match(/["'](https?:\/\/(?!localhost)[^"']+)["']/);
708
+ if (httpMatch) {
709
+ issues.push({
710
+ level: "warn",
711
+ dir: page.dir,
712
+ text: "检测到硬编码 URL " + httpMatch[1] + "(standard 07:建议使用环境变量)",
713
+ rule: "R12",
714
+ });
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ // R4 后处理:收集重复 cid(用 Set.size 而非 array.length)
721
+ for (const [cid, dirs] of globalCidMap.entries()) {
722
+ if (dirs.size > 1) {
723
+ const dirArray = Array.from(dirs);
724
+ issues.push({
725
+ level: "error",
726
+ dir: dirArray.join(" | "),
727
+ text: 'cid "' + cid + '" 在 ' + dirArray.length + " 个页面中重复使用",
728
+ rule: "R4",
729
+ });
730
+ }
731
+ }
732
+
733
+ return { issues, pages: pages.length, astAvailable: true };
734
+ }
735
+
736
+ /**
737
+ * 获取 git staged 文件列表(.vue/.ts)
738
+ * @param {string} targetDir
739
+ * @returns {string[]} 相对路径数组
740
+ */
741
+ function getStagedFiles(targetDir) {
742
+ try {
743
+ const output = execFileSync(
744
+ "git",
745
+ ["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
746
+ { cwd: targetDir, encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024 },
747
+ );
748
+ return output
749
+ .trim()
750
+ .split("\n")
751
+ .filter((f) => f && /\.(vue|ts)$/.test(f));
752
+ } catch {
753
+ return [];
754
+ }
755
+ }
756
+
757
+ module.exports = {
758
+ runAstRules,
759
+ parseVueScript,
760
+ countEffectiveLines,
761
+ extractScriptInfo,
762
+ hasAstAvailable,
763
+ isAstFunctionallyUsable,
764
+ getStagedFiles,
765
+ isLikelyListPage,
766
+ isListTypePage,
767
+ hasIgnoreMarker,
768
+ CONFIG,
769
+ };