repo-audit-tool 1.3.3 → 1.4.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.
package/PUBLISHING.md CHANGED
@@ -10,6 +10,10 @@
10
10
 
11
11
  ## 版本历史
12
12
 
13
+ - **1.4.0** — json_field workspace 递归:monorepo 场景下递归检查所有 workspace 包
14
+ 的 license 字段;findWorkspacePackages(npm workspaces 数组 / pnpm workspaces.packages /
15
+ 常见目录扫描兜底)+ checkJsonFieldContent 提取复用 + DOC-003b 启用;
16
+ 15 用例回归(2026-09-08)
13
17
  - **1.3.2** — Issue#4 修复:入口守卫 `pathToFileURL` 归一化,修复 Windows 全形态
14
18
  静默不执行(v1.3.1 回归);守卫 stderr 诊断提示;command 检查器 Git Bash 探测;
15
19
  CI 加 windows-latest 矩阵 + npm 安装 smoke 步骤(2026-09-07)
@@ -2,12 +2,11 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "repo-audit 文档索引",
4
4
  "description": "AI Agent 可读的文档索引 — 包含所有文档的元数据、结构、和调用协议",
5
- "version": "1.3.2",
6
- "updated": "2026-09-07",
5
+ "version": "1.4.1",
6
+ "updated": "2026-09-09",
7
7
  "repo": "NinjaSln-labs/repo-audit",
8
8
  "npm": "repo-audit-tool",
9
9
  "pages": "https://ninjasln-labs.github.io/repo-audit/",
10
-
11
10
  "entry_points": {
12
11
  "json_index": "/AGENT-INDEX.json",
13
12
  "schema": "/SCHEMA.json",
@@ -15,33 +14,72 @@
15
14
  "human_guide": "/repo-audit/HUMAN-GUIDE.md",
16
15
  "agent_guide": "/repo-audit/AGENT-GUIDE.md"
17
16
  },
18
-
19
17
  "tool": {
20
18
  "name": "repo-audit",
21
19
  "description": "仓库标准化审计工具 + 工程脚手架生成器(零依赖)",
22
20
  "bin": "repo-audit",
23
- "version": "1.3.2",
21
+ "version": "1.4.1",
24
22
  "language": "JavaScript (ESM)",
25
23
  "runtime": "Node.js >= 18",
26
24
  "dependencies": [],
27
25
  "license": "MIT",
28
-
29
26
  "invocation": {
30
27
  "command": "repo-audit [options]",
31
28
  "options": {
32
- "--repo": { "type": "string", "default": ".", "description": "目标仓库路径" },
33
- "--type": { "type": "string", "description": "强制指定分类(跳过自动检测)" },
34
- "--format": { "type": "string", "enum": ["md", "json", "both"], "default": "both", "description": "输出格式" },
35
- "--output": { "type": "string", "default": "<repo>/audit-report/", "description": "输出目录" },
36
- "--llm-provider": { "type": "string", "description": "LLM 协议:openai/anthropic 或厂商别名" },
37
- "--llm-model": { "type": "string", "description": "模型名称" },
38
- "--strict": { "type": "boolean", "default": false, "description": "严格模式:Critical/Major 即 exit 1" },
39
- "--rules": { "type": "string", "description": "自定义规则 YAML(可多次)" },
40
- "--dim": { "type": "string", "description": "只审计指定维度(可多次)" },
41
- "--feedback": { "type": "string", "description": "提交反馈(创建 GitHub Issue)" },
42
- "--help": { "type": "boolean", "description": "显示帮助" }
29
+ "--repo": {
30
+ "type": "string",
31
+ "default": ".",
32
+ "description": "目标仓库路径"
33
+ },
34
+ "--type": {
35
+ "type": "string",
36
+ "description": "强制指定分类(跳过自动检测)"
37
+ },
38
+ "--format": {
39
+ "type": "string",
40
+ "enum": [
41
+ "md",
42
+ "json",
43
+ "both"
44
+ ],
45
+ "default": "both",
46
+ "description": "输出格式"
47
+ },
48
+ "--output": {
49
+ "type": "string",
50
+ "default": "<repo>/audit-report/",
51
+ "description": "输出目录"
52
+ },
53
+ "--llm-provider": {
54
+ "type": "string",
55
+ "description": "LLM 协议:openai/anthropic 或厂商别名"
56
+ },
57
+ "--llm-model": {
58
+ "type": "string",
59
+ "description": "模型名称"
60
+ },
61
+ "--strict": {
62
+ "type": "boolean",
63
+ "default": false,
64
+ "description": "严格模式:Critical/Major 即 exit 1"
65
+ },
66
+ "--rules": {
67
+ "type": "string",
68
+ "description": "自定义规则 YAML(可多次)"
69
+ },
70
+ "--dim": {
71
+ "type": "string",
72
+ "description": "只审计指定维度(可多次)"
73
+ },
74
+ "--feedback": {
75
+ "type": "string",
76
+ "description": "提交反馈(创建 GitHub Issue)"
77
+ },
78
+ "--help": {
79
+ "type": "boolean",
80
+ "description": "显示帮助"
81
+ }
43
82
  },
44
-
45
83
  "exit_codes": {
46
84
  "0": "审计通过(无 Critical/Major 或 --strict 未启用)",
47
85
  "1": "有 Critical/Major 发现且 --strict 启用",
@@ -49,7 +87,6 @@
49
87
  "3": "目标不是 git 仓库"
50
88
  }
51
89
  },
52
-
53
90
  "environment_variables": {
54
91
  "LLM_PROVIDER": "LLM 协议(openai/anthropic/none),也可用厂商别名如 deepseek/qwen/glm",
55
92
  "LLM_API_KEY": "API Key(必须手动指定)",
@@ -58,35 +95,58 @@
58
95
  "REPO_AUDIT_TYPES": "自定义分类列表(JSON 数组)"
59
96
  }
60
97
  },
61
-
62
98
  "audit": {
63
99
  "dimensions": [
64
100
  {
65
101
  "id": "docs",
66
102
  "label": "文档",
67
- "rules": ["DOC-001", "DOC-002", "DOC-003", "DOC-003b", "DOC-004", "DOC-005", "DOC-006", "DOC-007"],
103
+ "rules": [
104
+ "DOC-001",
105
+ "DOC-002",
106
+ "DOC-003",
107
+ "DOC-003b",
108
+ "DOC-004",
109
+ "DOC-005",
110
+ "DOC-006",
111
+ "DOC-007"
112
+ ],
68
113
  "description": "README/LICENSE/CONTRIBUTING/SECURITY 等文档完整性"
69
114
  },
70
115
  {
71
116
  "id": "git",
72
117
  "label": "Git",
73
- "rules": ["GIT-001", "GIT-002", "GIT-003", "GIT-004"],
118
+ "rules": [
119
+ "GIT-001",
120
+ "GIT-002",
121
+ "GIT-003",
122
+ "GIT-004"
123
+ ],
74
124
  "description": "commit 规范、分支策略、scaffold 管理"
75
125
  },
76
126
  {
77
127
  "id": "quality",
78
128
  "label": "质量",
79
- "rules": ["QUA-001", "QUA-002", "QUA-003", "QUA-004", "QUA-005"],
129
+ "rules": [
130
+ "QUA-001",
131
+ "QUA-002",
132
+ "QUA-003",
133
+ "QUA-004",
134
+ "QUA-005"
135
+ ],
80
136
  "description": "构建配置、测试、lint、typecheck"
81
137
  },
82
138
  {
83
139
  "id": "security",
84
140
  "label": "安全",
85
- "rules": ["SEC-001", "SEC-002", "SEC-003", "SEC-004"],
141
+ "rules": [
142
+ "SEC-001",
143
+ "SEC-002",
144
+ "SEC-003",
145
+ "SEC-004"
146
+ ],
86
147
  "description": "密钥泄露、依赖漏洞、权限配置"
87
148
  }
88
149
  ],
89
-
90
150
  "scoring": {
91
151
  "formula": "score = 100 - (critical × 25) - (major × 10) - (minor × 3)",
92
152
  "grades": {
@@ -98,14 +158,26 @@
98
158
  },
99
159
  "note": "score 和 grade 不在 JSON 输出中,Agent 需根据 summary 字段自行计算"
100
160
  },
101
-
102
161
  "categories": [
103
- "dsh-plugin", "python-app", "go-service", "content", "sandbox",
104
- "archive", "product-oss", "javascript", "typescript", "java",
105
- "python", "rust", "cpp", "dotnet", "php", "ruby", "unknown"
162
+ "dsh-plugin",
163
+ "python-app",
164
+ "go-service",
165
+ "content",
166
+ "sandbox",
167
+ "archive",
168
+ "product-oss",
169
+ "javascript",
170
+ "typescript",
171
+ "java",
172
+ "python",
173
+ "rust",
174
+ "cpp",
175
+ "dotnet",
176
+ "php",
177
+ "ruby",
178
+ "unknown"
106
179
  ]
107
180
  },
108
-
109
181
  "documents": [
110
182
  {
111
183
  "id": "human-guide",
@@ -189,12 +261,10 @@
189
261
  "description": "JSON Schema 定义,用于验证审计报告输出"
190
262
  }
191
263
  ],
192
-
193
264
  "feedback": {
194
265
  "description": "反馈通道 — Agent 可通过 GitHub Issues API 检测和处理用户反馈",
195
266
  "label": "audit-feedback",
196
267
  "repo": "NinjaSln-labs/repo-audit",
197
-
198
268
  "detect": {
199
269
  "command": "gh issue list --repo NinjaSln-labs/repo-audit --label audit-feedback --state open --json number,title,createdAt,body",
200
270
  "description": "列出未处理的反馈"
@@ -211,15 +281,29 @@
211
281
  "command": "node repo-audit.mjs --feedback '<反馈内容>'",
212
282
  "description": "通过 CLI 创建反馈 Issue(自动预填环境信息)"
213
283
  },
214
-
215
284
  "priorities": [
216
- { "level": "critical", "sla": "24h", "description": "工具崩溃/数据损坏" },
217
- { "level": "major", "sla": "72h", "description": "功能严重受损" },
218
- { "level": "minor", "sla": "1周", "description": "功能部分受损" },
219
- { "level": "info", "sla": "排期", "description": "建议改进" }
285
+ {
286
+ "level": "critical",
287
+ "sla": "24h",
288
+ "description": "工具崩溃/数据损坏"
289
+ },
290
+ {
291
+ "level": "major",
292
+ "sla": "72h",
293
+ "description": "功能严重受损"
294
+ },
295
+ {
296
+ "level": "minor",
297
+ "sla": "1周",
298
+ "description": "功能部分受损"
299
+ },
300
+ {
301
+ "level": "info",
302
+ "sla": "排期",
303
+ "description": "建议改进"
304
+ }
220
305
  ]
221
306
  },
222
-
223
307
  "quick_start": {
224
308
  "install": "npm install -g repo-audit-tool",
225
309
  "audit": "repo-audit --repo /path/to/repo",
@@ -229,14 +313,16 @@
229
313
  "custom_rules": "repo-audit --repo /path/to/repo --rules my-rules.yaml",
230
314
  "llm_enhanced": "LLM_API_KEY=sk-xxx repo-audit --repo . --llm-provider deepseek"
231
315
  },
232
-
233
316
  "changelog": {
317
+ "1.4.0": "json_field workspace 递归——monorepo 场景下递归检查所有 workspace 包的 license 字段;findWorkspacePackages 发现 npm/pnpm workspaces + 常见目录;checkJsonFieldContent 提取为可复用函数;DOC-003b 启用;15 用例回归",
318
+ "1.3.3": "Issue#5 修复:.auditrc.yaml since 字段 YAML 引号剥离——loadAuditrc 中 since: 分支补 .replace(/['\"]/g, ''),与 reason 分支对称,修复 waived_since 输出含字面引号的 bug;5 用例回归(test/auditrc-since.test.mjs)",
234
319
  "1.3.2": "Issue#4 修复:入口守卫 pathToFileURL 归一化——修复 Windows 全形态静默不执行(v1.3.1 回归);守卫不命中时 stderr 诊断提示;command 检查器 Git Bash 探测;CI 双平台矩阵 + npm 安装 smoke",
235
320
  "1.3.1": "入口守卫 realpath 归一化——修复 npm bin symlink 下主流程不执行;1.3.0 反馈修复:注释剥离/SEC-004/AGENTS 声明/QUA-004/--update 动线",
236
321
  "1.3.0": "反馈修复:yaml_field 行内注释剥离 + fallback_field 通配(SEC-004 兼容 job 级权限);AGENTS-core 来源声明二选一;QUA-004 锁文件消费意图;手册补 --update 存量补齐动线",
237
322
  "1.2.3": "package.json 补充 repository 字段,修复 npm provenance E422;完成 Trusted Publisher tag 自动发布",
238
323
  "1.2.2": "publish CI 改用 node 22 + 条件升级 npm,修复 EBADENGINE(tag 发布因 1.2.1 已存在未出包)",
239
324
  "1.2.1": "README 链接改为绝对 URL(修复 npm 页面 404)",
240
- "1.2.0": "开源就绪:AGENTS.md、CI/publish workflow、json_field monorepo、fallback_field 数组、英文 README、参数类型校验、占位符扫描"
325
+ "1.2.0": "开源就绪:AGENTS.md、CI/publish workflow、json_field monorepo、fallback_field 数组、英文 README、参数类型校验、占位符扫描",
326
+ "1.4.1": "yaml_field 解析健壮性——CRLF 行尾剥离 + 引号键名识别 + 子层缩进以首个子键为准(SEC-004 job 级 id-token 误判 fail,Issue#6);6 用例回归"
241
327
  }
242
328
  }
@@ -275,6 +275,10 @@ EOF
275
275
  node repo-audit.mjs --repo /path/to/repo --rules /tmp/my-rules.yaml --format json
276
276
  ```
277
277
 
278
+ ### 4.7 yaml_field fallback_field 与解析健壮性(Issue#6)
279
+
280
+ `yaml_field` 支持 `fallback_field`(数组):主字段不匹配时逐个尝试备选路径,段内 `*` 为通配展开(如 `jobs.*.permissions.id-token`——任一 job 级命中即 pass,覆盖 PyPA 最小化权限模式)。解析层(v1.4.1)统一剥离 CRLF 行尾 `\r`、识别引号键名、按首个子键实际缩进确定子层(2/4 空格风格均支持)。误判排查时优先确认 workflow 文件是否踩过这三类形态。
281
+
278
282
  ---
279
283
 
280
284
  ## 五、错误处理协议
@@ -688,6 +688,15 @@ params:
688
688
  fallback_field: "scripts.test"
689
689
  ```
690
690
 
691
+ ### 12.4b yaml_field fallback_field 与解析健壮性(Issue#6)
692
+
693
+ `yaml_field` 支持 `fallback_field`(数组):主字段不匹配时逐个尝试备选路径,段内 `*` 为通配(如 `jobs.*.permissions.id-token`——顶层无 `permissions.id-token` 时,任一 job 级命中即 pass,覆盖 PyPA 最小化权限模式)。
694
+
695
+ 解析健壮性(v1.4.1 修复,Issue#6):
696
+ - **CRLF 行尾**:行解析前统一剥离 `\r`,Windows 编辑的 workflow 不再标量失配
697
+ - **引号键名**:`"publish":` / `'publish':` 与裸键等价识别
698
+ - **缩进风格**:子层缩进以首个子键实际缩进为准(2/4 空格等风格均支持,不再硬编码 +2)
699
+
691
700
  ### 12.5 toml_field expect_table
692
701
 
693
702
  检查 TOML 表头存在性(而非表内键值):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-audit-tool",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "仓库标准化审计工具 + 工程脚手架生成器(零依赖)",
5
5
  "type": "module",
6
6
  "main": "repo-audit.mjs",
package/repo-audit.mjs CHANGED
@@ -170,6 +170,129 @@ function findPackageJson(repoPath) {
170
170
  return null
171
171
  }
172
172
 
173
+ // ============================================================
174
+ // workspace 递归 — 发现所有 workspace 包(根 + workspace globs + 常见目录)
175
+ // ============================================================
176
+
177
+ export function findWorkspacePackages(repoPath) {
178
+ const results = []
179
+ const seen = new Set()
180
+
181
+ const tryAdd = (pkgPath) => {
182
+ const resolved = resolve(pkgPath)
183
+ if (seen.has(resolved)) return
184
+ seen.add(resolved)
185
+ // 跳过 node_modules 和隐藏目录
186
+ const parts = resolved.split('/').join('\/')
187
+ if (parts.includes('node_modules')) return
188
+ const pkg = readJsonSafe(pkgPath)
189
+ if (pkg) {
190
+ results.push({ path: pkgPath, pkg, dir: dirname(pkgPath) })
191
+ }
192
+ }
193
+
194
+ // 1. 根 package.json
195
+ const rootPkgPath = join(repoPath, 'package.json')
196
+ tryAdd(rootPkgPath)
197
+
198
+ // 2. 根 package.json 的 workspaces 字段(npm: 数组 / pnpm: {packages:[...]})
199
+ const rootPkg = readJsonSafe(rootPkgPath)
200
+ if (rootPkg?.workspaces) {
201
+ const globs = Array.isArray(rootPkg.workspaces)
202
+ ? rootPkg.workspaces
203
+ : (rootPkg.workspaces.packages || [])
204
+ for (const glob of globs) {
205
+ expandWorkspaceGlob(repoPath, glob, tryAdd)
206
+ }
207
+ }
208
+
209
+ // 3. 常见 workspace 目录扫描(无 workspaces 字段时的兜底)
210
+ const patterns = ['apps', 'packages', 'workspaces', 'components', 'modules', 'libs', 'projects']
211
+ for (const pattern of patterns) {
212
+ const patternDir = join(repoPath, pattern)
213
+ if (!existsSync(patternDir)) continue
214
+ try {
215
+ for (const entry of readdirSync(patternDir, { withFileTypes: true })) {
216
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue
217
+ tryAdd(join(patternDir, entry.name, 'package.json'))
218
+ }
219
+ } catch {}
220
+ }
221
+
222
+ return results
223
+ }
224
+
225
+ function expandWorkspaceGlob(repoPath, glob, tryAdd) {
226
+ // 直接路径(无 glob 字符)
227
+ if (!glob.includes('*')) {
228
+ tryAdd(join(repoPath, glob, 'package.json'))
229
+ return
230
+ }
231
+ // 常见 glob 模式:base/* 或 base/**/*
232
+ const baseDir = join(repoPath, glob.split('*')[0].replace(/\/$/, ''))
233
+ if (!existsSync(baseDir)) return
234
+ try {
235
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
236
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue
237
+ tryAdd(join(baseDir, entry.name, 'package.json'))
238
+ }
239
+ } catch {}
240
+ }
241
+
242
+ // ============================================================
243
+ // json_field 核心检查逻辑(供 workspace 递归复用)
244
+ // ============================================================
245
+
246
+ export function checkJsonFieldContent(content, params) {
247
+ const result = { passed: false, evidence: '', value: undefined }
248
+ try {
249
+ const obj = JSON.parse(content)
250
+ const fields = (params?.field || '').split('.')
251
+ let val = obj
252
+ let missing = false
253
+ let missingField = null
254
+ for (const f of fields) {
255
+ if (val === undefined || val === null) { missing = true; missingField = f; break }
256
+ val = val[f]
257
+ }
258
+ if (missing) {
259
+ result.evidence = `字段 ${missingField} 不存在`
260
+ } else if (val !== undefined && val !== null) {
261
+ result.passed = true
262
+ result.value = val
263
+ result.evidence = `字段 ${params.field} = ${JSON.stringify(val)?.slice(0, 100)}`
264
+ } else {
265
+ result.evidence = `字段 ${params.field} = ${JSON.stringify(val)?.slice(0, 100)}`
266
+ }
267
+ // fallback_field(如 peerDeps 不存在时检查 devDeps)
268
+ if (!result.passed && params?.fallback_field) {
269
+ const fbFields = Array.isArray(params.fallback_field)
270
+ ? params.fallback_field
271
+ : params.fallback_field.split('.')
272
+ for (const candidate of fbFields) {
273
+ const segs = Array.isArray(candidate) ? candidate : candidate.split('.')
274
+ let fbVal = obj
275
+ for (const f of segs) {
276
+ if (fbVal === undefined || fbVal === null) break
277
+ fbVal = fbVal[f]
278
+ }
279
+ if (fbVal !== undefined && fbVal !== null) {
280
+ result.passed = true
281
+ result.value = fbVal
282
+ const matchedField = Array.isArray(params.fallback_field)
283
+ ? String(candidate)
284
+ : params.fallback_field
285
+ result.evidence = `字段 ${params.field} 不存在,但 ${matchedField} = ${JSON.stringify(fbVal)?.slice(0, 100)}`
286
+ break
287
+ }
288
+ }
289
+ }
290
+ } catch (e) {
291
+ result.evidence = `JSON 解析失败: ${e.message}`
292
+ }
293
+ return result
294
+ }
295
+
173
296
  function readFileSafe(path) {
174
297
 
175
298
  try { return readFileSync(path, 'utf-8') } catch { return null }
@@ -202,30 +325,49 @@ function countNonHiddenFiles(path) {
202
325
  return count
203
326
  }
204
327
 
205
- // Issue#2 fix: 定位指定缩进层级下的具名子键(返回行号与缩进;未找到返回 null)
328
+ // Issue#6 fix: 规范化 YAML 行——剥离行尾 \r(CRLF 文件在 Linux 审计时 (.*)$ 不吞 \r,
329
+ // 标量值带尾 \r 导致与 expected 比较全部失配);行内键名引号在匹配处单独处理
330
+ function normalizeYamlLines(content) {
331
+ return String(content).split('\n').map(l => l.replace(/\r$/, ''))
332
+ }
333
+ // Issue#6 fix: YAML 键名匹配——兼容裸键与引号键("publish": / 'publish':),
334
+ // 返回 [key, valueRest];非键行返回 null
335
+ function matchYamlKey(l) {
336
+ const m = l.match(/^(\s*)(?:"([^"]+)"|'([^']+)'|(\w[\w\-]*))\s*:\s*(.*)$/)
337
+ if (!m) return null
338
+ return [m[2] ?? m[3] ?? m[4], m[5]]
339
+ }
340
+
341
+ // Issue#6 fix: 定位指定缩进层级下的具名子键(返回行号与缩进;未找到返回 null)
342
+ // 子层缩进以本层首个子键的实际缩进为准(兼容 2/4 空格等缩进风格),不再硬编码 +2
206
343
  function findNestedYamlKey(lines, startIdx, baseIndent, key) {
344
+ let childIndent = -1
207
345
  for (let i = startIdx + 1; i < lines.length; i++) {
208
346
  const l = lines[i]
209
347
  if (!l.trim() || l.trim().startsWith('#')) continue
210
348
  const ind = l.length - l.trimStart().length
211
349
  if (ind <= baseIndent) return null
212
- if (ind !== baseIndent + 2) continue // 只看直接子层(YAML 常规 2 空格缩进)
213
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
214
- if (m && m[1] === key) return { idx: i, indent: ind }
350
+ if (childIndent === -1) childIndent = ind // 本层首个子键确定子层缩进
351
+ if (ind !== childIndent) continue // 只看直接子层
352
+ const km = matchYamlKey(l)
353
+ if (km && km[0] === key) return { idx: i, indent: ind }
215
354
  }
216
355
  return null
217
356
  }
218
357
  // Issue#2 fix: 列出指定父键下一层的所有子键名(通配 * 展开用)
358
+ // 子层缩进同样以首个子键实际缩进为准(与 findNestedYamlKey 对称)
219
359
  function listChildKeys(lines, startIdx, baseIndent) {
220
360
  const keys = []
361
+ let childIndent = -1
221
362
  for (let i = startIdx + 1; i < lines.length; i++) {
222
363
  const l = lines[i]
223
364
  if (!l.trim() || l.trim().startsWith('#')) continue
224
365
  const ind = l.length - l.trimStart().length
225
366
  if (ind <= baseIndent) break
226
- if (ind !== baseIndent + 2) continue
227
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
228
- if (m) keys.push(m[1])
367
+ if (childIndent === -1) childIndent = ind
368
+ if (ind !== childIndent) continue
369
+ const km = matchYamlKey(l)
370
+ if (km) keys.push(km[0])
229
371
  }
230
372
  return keys
231
373
  }
@@ -251,10 +393,10 @@ function findNestedYaml(lines, startIdx, baseIndent, segments) {
251
393
  if (!l.trim() || l.trim().startsWith('#')) continue
252
394
  const ind = l.length - l.trimStart().length
253
395
  if (ind <= currentIndent) break // 退出当前层级
254
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
255
- if (m) {
256
- const key = m[1]
257
- const valStr = stripYamlComment(m[2])
396
+ const km = matchYamlKey(l)
397
+ if (km) {
398
+ const key = km[0]
399
+ const valStr = stripYamlComment(km[1])
258
400
  const seg = segments[0]
259
401
  if (key === seg) {
260
402
  if (valStr && valStr !== '' && valStr !== '{}') {
@@ -724,7 +866,7 @@ function loadRules(type, customPaths = [], repoPath = null) {
724
866
  file_header: { path: ['string'], pattern: ['string'] },
725
867
  regex: { path: ['string'], pattern: ['string'] },
726
868
  grep: { pattern: ['string'] },
727
- json_field: { path: ['string'], field: ['string'], fallback_field: ['array', 'string'] },
869
+ json_field: { path: ['string'], field: ['string'], fallback_field: ['array', 'string'], workspace_recursive: ['boolean'] },
728
870
  toml_field: { path: ['string'], field: ['string'] },
729
871
  yaml_field: { path: ['string'], field: ['string'], fallback_field: ['array', 'string'] },
730
872
  directory_exists: { paths: ['array', 'string'] },
@@ -779,6 +921,11 @@ function hasEquivalentDocs(repoPath) {
779
921
  // 规则检查引擎
780
922
  // ============================================================
781
923
 
924
+ // Issue#6 回归需要进程内直调(win32 下 spawnSync node 走 PATHEXT 解析不可靠,P-007 同族坑)
925
+ export function runCheckForTest(rule, repoPath, mpj = null) {
926
+ return runCheck(rule, repoPath, mpj)
927
+ }
928
+
782
929
  function runCheck(rule, repoPath, mpj = null) {
783
930
  const { check, params } = rule
784
931
  let passed = false
@@ -962,53 +1109,45 @@ function runCheck(rule, repoPath, mpj = null) {
962
1109
  }
963
1110
 
964
1111
  case 'json_field': {
1112
+ const pathParam = params?.path
1113
+ const workspaceRecursive = params?.workspace_recursive === true
1114
+
1115
+ // workspace 递归:检查所有 workspace 包
1116
+ if (workspaceRecursive && pathParam === 'package.json') {
1117
+ const allPkgs = findWorkspacePackages(repoPath)
1118
+ if (allPkgs.length === 0) {
1119
+ passed = false; evidence = '未找到任何 package.json'; break
1120
+ }
1121
+ const results = []
1122
+ let allPassed = true
1123
+ for (const wp of allPkgs) {
1124
+ const content = readFileSafe(wp.path)
1125
+ if (!content) {
1126
+ results.push(`${wp.dir === repoPath ? '根' : relative(repoPath, wp.dir)}: 文件不可读 ✗`)
1127
+ allPassed = false; continue
1128
+ }
1129
+ const r = checkJsonFieldContent(content, params)
1130
+ const relPath = wp.dir === repoPath ? '根' : relative(repoPath, wp.dir)
1131
+ results.push(`${relPath}: ${r.evidence} ${r.passed ? '✓' : '✗'}`)
1132
+ if (!r.passed) allPassed = false
1133
+ }
1134
+ passed = allPassed
1135
+ evidence = results.join(' | ')
1136
+ break
1137
+ }
1138
+
965
1139
  // P6: monorepo 感知 — package.json 不在根时 fallback 到 workspace 目录
966
- let actualPath = join(repoPath, params?.path)
1140
+ let actualPath = join(repoPath, pathParam)
967
1141
  let workspaceHint = ''
968
- if (!readFileSafe(actualPath) && params?.path === 'package.json' && mpj) {
1142
+ if (!readFileSafe(actualPath) && pathParam === 'package.json' && mpj) {
969
1143
  actualPath = mpj.path
970
1144
  workspaceHint = `(workspace: ${mpj.dir})`
971
1145
  }
972
1146
  const content = readFileSafe(actualPath)
973
1147
  if (!content) { passed = false; evidence = '文件不存在'; break }
974
- try {
975
- const obj = JSON.parse(content)
976
- const fields = (params?.field || '').split('.')
977
- let val = obj
978
- let missing = false
979
- for (const f of fields) {
980
- if (val === undefined || val === null) { passed = false; evidence = `字段 ${f} 不存在`; missing = true; break }
981
- val = val[f]
982
- }
983
- if (!missing) {
984
- passed = val !== undefined && val !== null
985
- evidence = `字段 ${params.field} = ${JSON.stringify(val)?.slice(0, 100)}${workspaceHint}`
986
- }
987
- // P1-2 fix: 支持 fallback_field(如 peerDeps 不存在时检查 devDeps)
988
- // 支持两种格式:数组 ["deps", "peerDeps", "devDeps"] 或字符串 "deps"(点路径)
989
- if (!passed && params?.fallback_field) {
990
- const fbFields = Array.isArray(params.fallback_field)
991
- ? params.fallback_field
992
- : params.fallback_field.split('.')
993
- // 对每个候选字段,尝试在 obj 中查找
994
- for (const candidate of fbFields) {
995
- const segs = Array.isArray(candidate) ? candidate : candidate.split('.')
996
- let fbVal = obj
997
- for (const f of segs) {
998
- if (fbVal === undefined || fbVal === null) break
999
- fbVal = fbVal[f]
1000
- }
1001
- if (fbVal !== undefined && fbVal !== null) {
1002
- passed = true
1003
- const matchedField = Array.isArray(params.fallback_field)
1004
- ? String(candidate)
1005
- : params.fallback_field
1006
- evidence = `字段 ${params.field} 不存在,但 ${matchedField} = ${JSON.stringify(fbVal)?.slice(0, 100)}${workspaceHint}`
1007
- break
1008
- }
1009
- }
1010
- }
1011
- } catch (e) { passed = false; evidence = `JSON 解析失败: ${e.message}` }
1148
+ const r = checkJsonFieldContent(content, params)
1149
+ passed = r.passed
1150
+ evidence = r.evidence + (workspaceHint || '')
1012
1151
  break
1013
1152
  }
1014
1153
 
@@ -1072,7 +1211,7 @@ function runCheck(rule, repoPath, mpj = null) {
1072
1211
  let val = null
1073
1212
  let found = false
1074
1213
  try {
1075
- const lines = content.split('\n')
1214
+ const lines = normalizeYamlLines(content)
1076
1215
  let currentIndent = -1
1077
1216
  let currentObj = null
1078
1217
  // 第一遍:找到顶层键
@@ -1080,10 +1219,10 @@ function runCheck(rule, repoPath, mpj = null) {
1080
1219
  const l = lines[i]
1081
1220
  if (!l.trim() || l.trim().startsWith('#')) continue
1082
1221
  const ind = l.length - l.trimStart().length
1083
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
1084
- if (m && m[1] === segments[0]) {
1222
+ const km = matchYamlKey(l)
1223
+ if (km && km[0] === segments[0]) {
1085
1224
  currentIndent = ind
1086
- const valStr = stripYamlComment(m[2])
1225
+ const valStr = stripYamlComment(km[1])
1087
1226
  if (valStr && valStr !== '' && valStr !== '{}') {
1088
1227
  // 标量值(非嵌套)
1089
1228
  val = valStr
@@ -1118,7 +1257,7 @@ function runCheck(rule, repoPath, mpj = null) {
1118
1257
  const wIdx = segs.indexOf('*')
1119
1258
  if (wIdx === -1) { candidates.push({ segments: segs }); return }
1120
1259
  // 找通配段父级的所有子键:先定位父级前缀(逐段下钻收集行号)
1121
- const lines = content.split('\n')
1260
+ const lines = normalizeYamlLines(content)
1122
1261
  const prefix = segs.slice(0, wIdx)
1123
1262
  const suffix = segs.slice(wIdx + 1)
1124
1263
  // 顶层段
@@ -1126,8 +1265,8 @@ function runCheck(rule, repoPath, mpj = null) {
1126
1265
  for (let i = 0; i < lines.length; i++) {
1127
1266
  const l = lines[i]
1128
1267
  if (!l.trim() || l.trim().startsWith('#')) continue
1129
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
1130
- if (!m || m[1] !== prefix[0]) continue
1268
+ const km = matchYamlKey(l)
1269
+ if (!km || km[0] !== prefix[0]) continue
1131
1270
  // 沿 prefix 下钻
1132
1271
  let curIdx = i, curIndent = l.length - l.trimStart().length
1133
1272
  let ok = true
@@ -1148,14 +1287,14 @@ function runCheck(rule, repoPath, mpj = null) {
1148
1287
  let fbVal = null
1149
1288
  let fbFound = false
1150
1289
  try {
1151
- const lines = content.split('\n')
1290
+ const lines = normalizeYamlLines(content)
1152
1291
  for (let i = 0; i < lines.length; i++) {
1153
1292
  const l = lines[i]
1154
1293
  if (!l.trim() || l.trim().startsWith('#')) continue
1155
1294
  const ind = l.length - l.trimStart().length
1156
- const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
1157
- if (m && m[1] === cand.segments[0]) {
1158
- const v = stripYamlComment(m[2])
1295
+ const km = matchYamlKey(l)
1296
+ if (km && km[0] === cand.segments[0]) {
1297
+ const v = stripYamlComment(km[1])
1159
1298
  if (v && v !== '' && v !== '{}') { fbVal = v; fbFound = true }
1160
1299
  else {
1161
1300
  const sub = findNestedYaml(lines, i, ind, cand.segments.slice(1))
@@ -43,7 +43,8 @@ rules:
43
43
  path: "package.json"
44
44
  field: "license"
45
45
  fallback_field: ["dependencies", "peerDependencies", "devDependencies"]
46
- description: "package.json 应有 license 字段(SPDX 标识)"
46
+ workspace_recursive: true
47
+ description: "package.json 应有 license 字段(SPDX 标识);monorepo 需所有 workspace 包均声明 license 字段"
47
48
  note: "仅对含 package.json 的 JS 项目检查"
48
49
 
49
50
  - id: DOC-004