dsh-d365-kit 0.34.1 → 0.34.3

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/code-check.js CHANGED
@@ -1,549 +1,549 @@
1
- /**
2
- * Diff-driven code check: only what this merge request touches is inspected.
3
- *
4
- * The chain is: merge request → changed files → Dataverse component (web
5
- * resource / form event) → who references it → does the target environment
6
- * still satisfy what the changed lines assume.
7
- *
8
- * Scope discipline matters more than coverage here. Scanning every script in
9
- * the repository produces hundreds of pre-existing mismatches and buries the
10
- * one field this commit forgot to put on the form. So every finding carries
11
- * the changed line it came from, and nothing outside the diff is inspected.
12
- */
13
- import { loadConfig } from './config.js'
14
- import { DataverseClient } from './client.js'
15
- import { D365Error } from './errors.js'
16
- import { fetchMergeRequest } from './gitlab.js'
17
- import { analyzeCsharpFile, isCsharpPath } from './csharp-check.js'
18
- import { dataRoot } from './snapshot.js'
19
- import { enforceRetention } from './retention.js'
20
- import { mkdir, writeFile } from 'node:fs/promises'
21
- import { join } from 'node:path'
22
-
23
- /** Paths whose diff is inspectable. Everything else is reported, not parsed. */
24
- const CODE_EXTENSIONS = new Set(['.js', '.ts'])
25
-
26
- /** How a changed line reads values off the form. Compiled bundles use several. */
27
- const ATTRIBUTE_PATTERNS = [
28
- /\bgetAttribute\(\s*["']([^"']+)["']/g,
29
- /\bgetControl\(\s*["']([^"']+)["']/g,
30
- /\.attributes\.get\(\s*["']([^"']+)["']/g,
31
- /\battributes\[\s*["']([^"']+)["']\s*\]/g,
32
- // Compiled bundles often go through a wrapper: ctx.getAttribute("x"),
33
- // this._formContext.data.entity.attributes.get("x").
34
- /\bdata\.entity\.attributes\.get\(\s*["']([^"']+)["']/g,
35
- /\bformContext\.getAttribute\(\s*["']([^"']+)["']/g,
36
- /\bXrm\.Page\.getAttribute\(\s*["']([^"']+)["']/g,
37
- ]
38
-
39
- /**
40
- * `columns.get("x")` reads a view or sub-grid column, not a form field. Keeping
41
- * it out of the attribute list matters: a column that is not on the form is
42
- * normal, and treating it as a missing field turned every grid change into a
43
- * blocker.
44
- */
45
- const COLUMN_PATTERNS = [/\.columns\.get\(\s*["']([^"']+)["']/g]
46
-
47
- /** Bracket access with a publisher prefix: the shape is a name, nothing more. */
48
- const GUESS_PATTERNS = [/\[["']([a-z][a-z0-9_]*_[a-z0-9_]+)["']\]/g]
49
-
50
- const WEBAPI_PATTERN = /Xrm\.WebApi\.[A-Za-z]+\(\s*["']([^"']+)["']/g
51
- const GUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
52
- const URL_PATTERN = /https?:\/\/[^\s"'`)]+/gi
53
- const OPTION_PATTERN = /(?:===?|!==?)\s*(\d{6,9})\b/g
54
- const FUNCTION_PATTERNS = [
55
- /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g,
56
- /\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*function\b/g,
57
- /^([A-Za-z_$][\w$]*)\s*=\s*function\b/gm,
58
- ]
59
-
60
- /** `OData` string literal: the only escape that matters is a single quote. */
61
- function odataString(value) {
62
- return String(value).replace(/'/g, "''")
63
- }
64
-
65
- /** Last path segment; the mapping between repo paths and resource names is unreliable. */
66
- function basename(path) {
67
- return String(path).split('/').filter(Boolean).pop() ?? ''
68
- }
69
-
70
- function extension(path) {
71
- const match = String(path).match(/\.[a-z0-9]+$/i)
72
- return match ? match[0].toLowerCase() : ''
73
- }
74
-
75
- /**
76
- * Split a unified diff into added and removed lines with their new-file line
77
- * numbers. Context lines are ignored: they are not this change's fault.
78
- */
79
- export function parseDiff(diff) {
80
- const added = []
81
- const removed = []
82
- let newLine = 0
83
- for (const raw of String(diff ?? '').split('\n')) {
84
- const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
85
- if (hunk) {
86
- newLine = Number(hunk[1])
87
- continue
88
- }
89
- if (raw.startsWith('+++') || raw.startsWith('---') || raw.startsWith('\\')) continue
90
- if (raw.startsWith('+')) {
91
- added.push({ line: newLine, text: raw.slice(1) })
92
- newLine += 1
93
- continue
94
- }
95
- if (raw.startsWith('-')) {
96
- removed.push({ text: raw.slice(1) })
97
- continue
98
- }
99
- if (raw.length && !raw.startsWith('-')) newLine += 1
100
- }
101
- return { added, removed }
102
- }
103
-
104
- function collect(patterns, text) {
105
- const found = new Map()
106
- for (const pattern of patterns) {
107
- pattern.lastIndex = 0
108
- for (const match of text.matchAll(pattern)) {
109
- const value = match[1]
110
- if (value && !found.has(value)) found.set(value, match.index ?? 0)
111
- }
112
- }
113
- return [...found.keys()]
114
- }
115
-
116
- /** Every external thing the added lines touch in one changed file. */
117
- export function extractReferences(addedLines) {
118
- const text = addedLines.map((entry) => entry.text).join('\n')
119
- const lineOf = (value) => addedLines.find((entry) => entry.text.includes(value))?.line ?? null
120
- const attributes = collect(ATTRIBUTE_PATTERNS, text).map((name) => ({ name, line: lineOf(name) }))
121
- const columns = collect(COLUMN_PATTERNS, text).map((name) => ({ name, line: lineOf(name) }))
122
- const guesses = collect(GUESS_PATTERNS, text)
123
- .filter((name) => !attributes.some((ref) => ref.name === name))
124
- .map((name) => ({ name, line: lineOf(name) }))
125
- const entities = [...new Set([...text.matchAll(WEBAPI_PATTERN)].map((m) => m[1]))]
126
- const guids = [...new Set([...text.matchAll(GUID_PATTERN)].map((m) => m[0].toLowerCase()))]
127
- const urls = [...new Set([...text.matchAll(URL_PATTERN)].map((m) => m[0]))]
128
- const optionValues = [...new Set([...text.matchAll(OPTION_PATTERN)].map((m) => Number(m[1])))]
129
- return { attributes, columns, guesses, entities, guids, urls, optionValues }
130
- }
131
-
132
- /** Function names a diff takes away — the ones whose removal breaks a form handler. */
133
- export function extractRemovedFunctions(removedLines) {
134
- const text = removedLines.map((entry) => entry.text).join('\n')
135
- return collect(FUNCTION_PATTERNS, text)
136
- }
137
-
138
- /** Pick the resource a changed file most likely is, and say when it is a guess. */
139
- async function mapWebResource(client, path, signal) {
140
- const base = basename(path)
141
- if (!base) return { resourceName: null, candidates: [], certain: false }
142
- const page = await client.getPaged('webresourceset', {
143
- $select: 'name',
144
- $filter: `contains(name,'${odataString(base)}')`,
145
- }, 100, signal)
146
- const names = page.records.map((row) => row.name).filter(Boolean)
147
- if (names.length === 1) return { resourceName: names[0], candidates: names, certain: true }
148
- if (!names.length) return { resourceName: null, candidates: [], certain: false }
149
- // Several resources share the basename: prefer one whose path suffix matches
150
- // the repository path, otherwise leave the choice to a human.
151
- const suffix = String(path).replace(/^\/+/, '')
152
- const suffixMatch = names.filter((name) => suffix.endsWith(name) || name.endsWith(suffix))
153
- if (suffixMatch.length === 1) return { resourceName: suffixMatch[0], candidates: names, certain: true }
154
- return { resourceName: suffixMatch[0] ?? names[0], candidates: names, certain: false }
155
- }
156
-
157
- /** Forms whose formxml binds this library, verified against the exact attribute. */
158
- async function findFormConsumers(client, resourceName, signal) {
159
- const page = await client.getPaged('systemforms', {
160
- $select: 'name,type,objecttypecode,formxml',
161
- $filter: `contains(formxml,'${odataString(resourceName)}')`,
162
- }, 200, signal)
163
- return page.records.filter((row) =>
164
- String(row.formxml ?? '').includes(`libraryName="${resourceName}"`)
165
- || String(row.formxml ?? '').includes(`libraryName='${resourceName}'`))
166
- }
167
-
168
- /** Field names present on one form, plus the handlers it declares. */
169
- export function describeForm(formxml) {
170
- const xml = String(formxml ?? '')
171
- const fields = new Set([...xml.matchAll(/datafieldname="([^"]+)"/gi)].map((m) => m[1]))
172
- const handlers = [...xml.matchAll(/<Handler\b[^>]*>/gi)].map((m) => {
173
- const functionName = m[0].match(/functionName="([^"]+)"/i)?.[1] ?? ''
174
- const libraryName = m[0].match(/libraryName="([^"]+)"/i)?.[1] ?? ''
175
- return { functionName, libraryName }
176
- })
177
- return { fields, handlers }
178
- }
179
-
180
- function attributeOnEntity(client, entity, attribute, signal) {
181
- return client.getPaged(`EntityDefinitions(LogicalName='${odataString(entity)}')/Attributes`, {
182
- $select: 'LogicalName,AttributeType',
183
- $filter: `LogicalName eq '${odataString(attribute)}'`,
184
- }, 1, signal).then((page) => page.records[0] ?? null).catch(() => null)
185
- }
186
-
187
- async function entityNames(client, signal) {
188
- const names = new Set()
189
- try {
190
- const page = await client.getPaged('EntityDefinitions', { $select: 'LogicalName' }, 3000, signal)
191
- for (const row of page.records) if (row.LogicalName) names.add(String(row.LogicalName).toLowerCase())
192
- } catch {
193
- // A tenant that refuses the metadata listing keeps this check silent.
194
- }
195
- return names
196
- }
197
-
198
- function resolveTargets(config, requested) {
199
- const keys = Object.keys(config.environments)
200
- const wanted = Array.isArray(requested) && requested.length ? requested : keys
201
- const unknown = wanted.filter((key) => !config.environments[key])
202
- if (unknown.length) {
203
- throw new D365Error(`未知环境:${unknown.join(', ')}`, {
204
- code: 'unknown-environment',
205
- hint: `可用环境:${keys.join(', ')}`,
206
- })
207
- }
208
- return wanted
209
- }
210
-
211
- /**
212
- * Analyze one merge request against the configured environments.
213
- *
214
- * @param config - loaded plugin config.
215
- * @param request - `{ mergeRequest, project?, targets? }`.
216
- * @returns a report object; findings are limited to lines the MR changed.
217
- */
218
- /** Drop human items from `from` on that repeat an earlier object + question. */
219
- function dropDuplicateHuman(human, from) {
220
- const seen = new Set(human.slice(0, from).map((item) => `${item.object}|${item.question}`))
221
- const kept = []
222
- for (let index = from; index < human.length; index += 1) {
223
- const item = human[index]
224
- const key = `${item.object}|${item.question}`
225
- if (seen.has(key)) continue
226
- seen.add(key)
227
- kept.push(item)
228
- }
229
- human.length = from
230
- human.push(...kept)
231
- }
232
-
233
- export async function analyzeMergeRequest(config, request, signal) {
234
- const merge = await fetchMergeRequest(config, request, signal)
235
- const targetKeys = resolveTargets(config, request?.targets)
236
-
237
- const files = []
238
- const findings = []
239
- const human = []
240
-
241
- let humanId = 0
242
- const askHuman = (entry) => {
243
- humanId += 1
244
- human.push({ id: `HC-${String(humanId).padStart(2, '0')}`, ...entry })
245
- }
246
-
247
- const codeChanges = merge.changes.filter((change) => CODE_EXTENSIONS.has(extension(change.newPath)))
248
- for (const change of merge.changes) {
249
- if (CODE_EXTENSIONS.has(extension(change.newPath)) || isCsharpPath(change.newPath)) continue
250
- files.push({ path: change.newPath, kind: 'other', note: '非脚本文件,本次不做静态检查' })
251
- }
252
-
253
- for (const change of codeChanges) {
254
- const { added, removed } = parseDiff(change.diff)
255
- const refs = extractReferences(added)
256
- const removedFunctions = extractRemovedFunctions(removed)
257
- const fileReport = {
258
- path: change.newPath,
259
- kind: change.deletedFile ? 'deleted' : change.renamedFile ? 'renamed' : change.newFile ? 'added' : 'modified',
260
- addedLines: added.length,
261
- removedLines: removed.length,
262
- references: refs,
263
- removedFunctions,
264
- targets: [],
265
- }
266
-
267
- if (!added.length && !removed.length) {
268
- files.push(fileReport)
269
- continue
270
- }
271
-
272
- for (const targetKey of targetKeys) {
273
- const env = config.environments[targetKey]
274
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
275
- const targetReport = { target: targetKey, resourceName: null, candidates: [], certain: false, forms: [], findings: 0 }
276
-
277
- try {
278
- const mapping = await mapWebResource(client, change.newPath, signal)
279
- Object.assign(targetReport, mapping)
280
- if (!mapping.resourceName) {
281
- findings.push({
282
- severity: 'warning',
283
- target: targetKey,
284
- file: change.newPath,
285
- line: null,
286
- message: `在 ${targetKey} 找不到文件名匹配的 Web 资源(${basename(change.newPath)})`,
287
- })
288
- askHuman({
289
- object: change.newPath,
290
- question: `这个文件在 ${targetKey} 对应哪个 Web 资源?`,
291
- evidence: `按文件名 '${basename(change.newPath)}' 搜索,没有命中。`,
292
- howToCheck: '在解决方案里找到该脚本的资源名,或在仓库里确认构建后的文件名。',
293
- impact: '映射不上就无法检查表单引用,改动的运行时影响未知。',
294
- })
295
- fileReport.targets.push(targetReport)
296
- continue
297
- }
298
- if (!mapping.certain) {
299
- askHuman({
300
- object: change.newPath,
301
- question: `这个文件对应 ${mapping.resourceName} 吗?候选:${mapping.candidates.join('、')}`,
302
- evidence: '按文件名匹配到多个资源,已按路径后缀选了最接近的一个。',
303
- howToCheck: '核对仓库路径与环境里的 Web 资源名。',
304
- impact: '映射错了,后面的检查对象就错了。',
305
- })
306
- }
307
-
308
- const forms = await findFormConsumers(client, mapping.resourceName, signal)
309
- targetReport.forms = forms.map((form) => ({
310
- name: form.name,
311
- type: form.type,
312
- entity: form.objecttypecode,
313
- }))
314
-
315
- if (change.deletedFile && forms.length) {
316
- findings.push({
317
- severity: 'blocker',
318
- target: targetKey,
319
- file: change.newPath,
320
- line: null,
321
- message: `删除了 Web 资源 ${mapping.resourceName},${targetKey} 里仍有 ${forms.length} 张表单引用它`,
322
- })
323
- }
324
-
325
- // One library is usually bound to several forms, and a field only has to
326
- // exist on the forms that actually reach the changed line. Collect per
327
- // field and decide once: missing everywhere is a blocker, missing on
328
- // some of the forms is a warning, not a blocker per form.
329
- const fieldUsage = new Map()
330
- let entities = null
331
- for (const form of forms) {
332
- const { fields, handlers } = describeForm(form.formxml)
333
- const entity = String(form.objecttypecode ?? '')
334
-
335
- for (const ref of refs.attributes) {
336
- const entry = fieldUsage.get(ref.name) ?? { name: ref.name, line: ref.line, present: [], missing: [] }
337
- if (fields.has(ref.name)) entry.present.push(form.name)
338
- else entry.missing.push({ form: form.name, entity })
339
- fieldUsage.set(ref.name, entry)
340
- }
341
-
342
- for (const removedName of removedFunctions) {
343
- const hit = handlers.some((handler) => handler.functionName.split('.').pop() === removedName)
344
- if (!hit) continue
345
- findings.push({
346
- severity: 'blocker',
347
- target: targetKey,
348
- file: change.newPath,
349
- line: null,
350
- message: `删除了函数 ${removedName},但 ${targetKey} 的表单「${form.name}」仍把它注册为事件处理函数`,
351
- })
352
- }
353
- }
354
-
355
- for (const entry of fieldUsage.values()) {
356
- if (!entry.missing.length) continue
357
- if (entry.present.length) {
358
- findings.push({
359
- severity: 'warning',
360
- target: targetKey,
361
- file: change.newPath,
362
- line: entry.line,
363
- message: `改动的代码读取了 ${entry.name}:${targetKey} 上 ${entry.present.length} 张表单有这个字段,另有 ${entry.missing.length} 张没有(${entry.missing.map((item) => item.form).slice(0, 3).join('、')}${entry.missing.length > 3 ? '…' : ''})——确认脚本在这些表单上不会走到该分支`,
364
- })
365
- continue
366
- }
367
- findings.push({
368
- severity: 'blocker',
369
- target: targetKey,
370
- file: change.newPath,
371
- line: entry.line,
372
- message: `改动的代码读取了 ${entry.name},但 ${targetKey} 上引用该库的 ${entry.missing.length} 张表单都没有这个字段`,
373
- })
374
- if (!entities) entities = await entityNames(client, signal)
375
- for (const { entity } of entry.missing) {
376
- if (!entity || entities.has(entity.toLowerCase())) continue
377
- askHuman({
378
- object: `${entity}.${entry.name}`,
379
- question: `表 ${entity} 在 ${targetKey} 不存在,确认实体名是否改过`,
380
- evidence: 'EntityDefinitions 里没有该实体。',
381
- howToCheck: '检查实体逻辑名与解决方案是否包含该表。',
382
- impact: '字段取值必然为 null,脚本会抛错。',
383
- })
384
- }
385
- }
386
-
387
- for (const ref of refs.columns ?? []) {
388
- askHuman({
389
- object: ref.name,
390
- question: `确认 ${ref.name} 是视图/子网格列,还是表单字段`,
391
- evidence: `改动行用 columns.get('${ref.name}') 取值;这个 API 读的是列,列不在表单上是正常的。`,
392
- howToCheck: '如果是表单字段,应改用 getAttribute;如果是列,确认视图或子网格在目标环境也有这一列。',
393
- impact: '列缺失时取值返回 null,不会报错,但界面会出现空白格。',
394
- })
395
- }
396
-
397
- for (const ref of refs.guesses ?? []) {
398
- askHuman({
399
- object: ref.name,
400
- question: `确认 ${ref.name} 在 ${targetKey} 里指的是字段、列还是别的键`,
401
- evidence: '改动行以带前缀的字符串下标取值,静态分析无法判断它是元数据还是普通对象键。',
402
- howToCheck: '在脚本上下文里看这个对象是什么;如果是表单字段,改用 getAttribute 以便下次能自动检查。',
403
- impact: '判断不了就可能漏掉一个真实的字段缺失。',
404
- })
405
- }
406
-
407
- // Entity names used through the Web API must exist in the target.
408
- if (refs.entities.length) {
409
- if (!entities) entities = await entityNames(client, signal)
410
- for (const name of refs.entities) {
411
- if (entities.has(name.toLowerCase())) continue
412
- findings.push({
413
- severity: 'blocker',
414
- target: targetKey,
415
- file: change.newPath,
416
- line: null,
417
- message: `改动的代码调用了 Xrm.WebApi 的 ${name},${targetKey} 里没有这个实体`,
418
- })
419
- }
420
- }
421
-
422
- for (const url of refs.urls) {
423
- findings.push({
424
- severity: 'warning',
425
- target: targetKey,
426
- file: change.newPath,
427
- line: null,
428
- message: `硬编码地址 ${url}`,
429
- })
430
- }
431
-
432
- for (const guid of refs.guids) {
433
- askHuman({
434
- object: guid,
435
- question: `确认 ${guid} 在 ${targetKey} 里指的是同一条记录`,
436
- evidence: '代码里出现硬编码 GUID,静态检查无法判断它属于哪张表。',
437
- howToCheck: '在源码里找到它的用途(配置记录 / 团队 / 队列 / 视图),再到目标环境按表查。',
438
- impact: '记录不存在时运行时返回空或抛错。',
439
- })
440
- }
441
-
442
- if (refs.optionValues.length) {
443
- askHuman({
444
- object: refs.optionValues.join(', '),
445
- question: `确认这些选项值在 ${targetKey} 的选项集里存在`,
446
- evidence: '代码用数字与选项值比较,静态检查无法可靠判断是哪个字段。',
447
- howToCheck: '定位比较语句对应的字段,再到目标环境查该选项集的值域。',
448
- impact: '值不存在时分支永远不进入,功能静默失效。',
449
- })
450
- }
451
-
452
- targetReport.findings = findings.filter((entry) => entry.target === targetKey).length
453
- } catch (error) {
454
- findings.push({
455
- severity: 'warning',
456
- target: targetKey,
457
- file: change.newPath,
458
- line: null,
459
- message: `检查失败:${error.message}`,
460
- })
461
- }
462
- fileReport.targets.push(targetReport)
463
- }
464
- files.push(fileReport)
465
- }
466
-
467
- for (const change of merge.changes) {
468
- if (!isCsharpPath(change.newPath)) continue
469
- const { added, removed } = parseDiff(change.diff)
470
- if (!added.length && !removed.length) {
471
- files.push({ path: change.newPath, kind: 'csharp', addedLines: 0, removedLines: 0, targets: [] })
472
- continue
473
- }
474
- const beforeHuman = human.length
475
- const result = await analyzeCsharpFile({
476
- change: { newPath: change.newPath, added, removed },
477
- config,
478
- targetKeys,
479
- signal,
480
- askHuman,
481
- })
482
- files.push(result.file)
483
- findings.push(...result.findings)
484
- dropDuplicateHuman(human, beforeHuman)
485
- }
486
-
487
- human.forEach((item, index) => {
488
- item.id = `HC-${String(index + 1).padStart(2, '0')}`
489
- })
490
-
491
- return {
492
- project: merge.project,
493
- iid: merge.iid,
494
- title: merge.title,
495
- webUrl: merge.webUrl,
496
- sourceBranch: merge.sourceBranch,
497
- targetBranch: merge.targetBranch,
498
- targets: targetKeys,
499
- files,
500
- findings,
501
- human,
502
- }
503
- }
504
-
505
- /** Write the markdown report beside the other artifacts and return its path. */
506
- export async function writeCodeCheckReport(config, report) {
507
- const directory = join(dataRoot(config), 'reports')
508
- await mkdir(directory, { recursive: true })
509
- const stamp = new Date().toISOString().replace(/[:.]/g, '-')
510
- const path = join(directory, `code-check-${report.iid}-${stamp}.md`)
511
- const lines = []
512
- lines.push(`# 代码改动影响检查 · MR !${report.iid}`, '')
513
- lines.push(`- 项目:${report.project}`)
514
- lines.push(`- 标题:${report.title || '(无标题)'}`)
515
- lines.push(`- 分支:${report.sourceBranch} → ${report.targetBranch}`)
516
- lines.push(`- 目标环境:${report.targets.join('、')}`)
517
- lines.push('')
518
- lines.push('## 改动的文件', '')
519
- for (const file of report.files) {
520
- lines.push(`- ${file.path}(${file.kind}):+${file.addedLines ?? 0} / -${file.removedLines ?? 0}`)
521
- }
522
- lines.push('', '## 阻断与警告', '')
523
- if (!report.findings.length) lines.push('没有发现元数据层面的阻断项。')
524
- for (const finding of report.findings) {
525
- const where = finding.line ? `:${finding.line}` : ''
526
- lines.push(`- [${finding.severity}] ${finding.target} · ${finding.file}${where} — ${finding.message}`)
527
- }
528
- lines.push('', '## 需要人工确认', '')
529
- if (!report.human.length) lines.push('没有需要人工确认的条目。')
530
- for (const item of report.human) {
531
- lines.push(`### ${item.id} ${item.object}`, '')
532
- lines.push(`- 问题:${item.question}`)
533
- lines.push(`- 证据:${item.evidence}`)
534
- lines.push(`- 怎么确认:${item.howToCheck}`)
535
- lines.push(`- 影响:${item.impact}`)
536
- lines.push('')
537
- }
538
- await writeFile(path, `${lines.join('\n')}\n`, 'utf8')
539
- return path
540
- }
541
-
542
- /** Load the config the plugin was given, then analyze. */
543
- export async function runCodeCheck(rawConfig, request, signal) {
544
- const config = await loadConfig(rawConfig)
545
- const report = await analyzeMergeRequest(config, request, signal)
546
- const reportPath = await writeCodeCheckReport(config, report)
547
- await enforceRetention(config, { target: 'reports' })
548
- return { report, reportPath }
549
- }
1
+ /**
2
+ * Diff-driven code check: only what this merge request touches is inspected.
3
+ *
4
+ * The chain is: merge request → changed files → Dataverse component (web
5
+ * resource / form event) → who references it → does the target environment
6
+ * still satisfy what the changed lines assume.
7
+ *
8
+ * Scope discipline matters more than coverage here. Scanning every script in
9
+ * the repository produces hundreds of pre-existing mismatches and buries the
10
+ * one field this commit forgot to put on the form. So every finding carries
11
+ * the changed line it came from, and nothing outside the diff is inspected.
12
+ */
13
+ import { loadConfig } from './config.js'
14
+ import { DataverseClient } from './client.js'
15
+ import { D365Error } from './errors.js'
16
+ import { fetchMergeRequest } from './gitlab.js'
17
+ import { analyzeCsharpFile, isCsharpPath } from './csharp-check.js'
18
+ import { dataRoot } from './snapshot.js'
19
+ import { enforceRetention } from './retention.js'
20
+ import { mkdir, writeFile } from 'node:fs/promises'
21
+ import { join } from 'node:path'
22
+
23
+ /** Paths whose diff is inspectable. Everything else is reported, not parsed. */
24
+ const CODE_EXTENSIONS = new Set(['.js', '.ts'])
25
+
26
+ /** How a changed line reads values off the form. Compiled bundles use several. */
27
+ const ATTRIBUTE_PATTERNS = [
28
+ /\bgetAttribute\(\s*["']([^"']+)["']/g,
29
+ /\bgetControl\(\s*["']([^"']+)["']/g,
30
+ /\.attributes\.get\(\s*["']([^"']+)["']/g,
31
+ /\battributes\[\s*["']([^"']+)["']\s*\]/g,
32
+ // Compiled bundles often go through a wrapper: ctx.getAttribute("x"),
33
+ // this._formContext.data.entity.attributes.get("x").
34
+ /\bdata\.entity\.attributes\.get\(\s*["']([^"']+)["']/g,
35
+ /\bformContext\.getAttribute\(\s*["']([^"']+)["']/g,
36
+ /\bXrm\.Page\.getAttribute\(\s*["']([^"']+)["']/g,
37
+ ]
38
+
39
+ /**
40
+ * `columns.get("x")` reads a view or sub-grid column, not a form field. Keeping
41
+ * it out of the attribute list matters: a column that is not on the form is
42
+ * normal, and treating it as a missing field turned every grid change into a
43
+ * blocker.
44
+ */
45
+ const COLUMN_PATTERNS = [/\.columns\.get\(\s*["']([^"']+)["']/g]
46
+
47
+ /** Bracket access with a publisher prefix: the shape is a name, nothing more. */
48
+ const GUESS_PATTERNS = [/\[["']([a-z][a-z0-9_]*_[a-z0-9_]+)["']\]/g]
49
+
50
+ const WEBAPI_PATTERN = /Xrm\.WebApi\.[A-Za-z]+\(\s*["']([^"']+)["']/g
51
+ const GUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
52
+ const URL_PATTERN = /https?:\/\/[^\s"'`)]+/gi
53
+ const OPTION_PATTERN = /(?:===?|!==?)\s*(\d{6,9})\b/g
54
+ const FUNCTION_PATTERNS = [
55
+ /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g,
56
+ /\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*function\b/g,
57
+ /^([A-Za-z_$][\w$]*)\s*=\s*function\b/gm,
58
+ ]
59
+
60
+ /** `OData` string literal: the only escape that matters is a single quote. */
61
+ function odataString(value) {
62
+ return String(value).replace(/'/g, "''")
63
+ }
64
+
65
+ /** Last path segment; the mapping between repo paths and resource names is unreliable. */
66
+ function basename(path) {
67
+ return String(path).split('/').filter(Boolean).pop() ?? ''
68
+ }
69
+
70
+ function extension(path) {
71
+ const match = String(path).match(/\.[a-z0-9]+$/i)
72
+ return match ? match[0].toLowerCase() : ''
73
+ }
74
+
75
+ /**
76
+ * Split a unified diff into added and removed lines with their new-file line
77
+ * numbers. Context lines are ignored: they are not this change's fault.
78
+ */
79
+ export function parseDiff(diff) {
80
+ const added = []
81
+ const removed = []
82
+ let newLine = 0
83
+ for (const raw of String(diff ?? '').split('\n')) {
84
+ const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
85
+ if (hunk) {
86
+ newLine = Number(hunk[1])
87
+ continue
88
+ }
89
+ if (raw.startsWith('+++') || raw.startsWith('---') || raw.startsWith('\\')) continue
90
+ if (raw.startsWith('+')) {
91
+ added.push({ line: newLine, text: raw.slice(1) })
92
+ newLine += 1
93
+ continue
94
+ }
95
+ if (raw.startsWith('-')) {
96
+ removed.push({ text: raw.slice(1) })
97
+ continue
98
+ }
99
+ if (raw.length && !raw.startsWith('-')) newLine += 1
100
+ }
101
+ return { added, removed }
102
+ }
103
+
104
+ function collect(patterns, text) {
105
+ const found = new Map()
106
+ for (const pattern of patterns) {
107
+ pattern.lastIndex = 0
108
+ for (const match of text.matchAll(pattern)) {
109
+ const value = match[1]
110
+ if (value && !found.has(value)) found.set(value, match.index ?? 0)
111
+ }
112
+ }
113
+ return [...found.keys()]
114
+ }
115
+
116
+ /** Every external thing the added lines touch in one changed file. */
117
+ export function extractReferences(addedLines) {
118
+ const text = addedLines.map((entry) => entry.text).join('\n')
119
+ const lineOf = (value) => addedLines.find((entry) => entry.text.includes(value))?.line ?? null
120
+ const attributes = collect(ATTRIBUTE_PATTERNS, text).map((name) => ({ name, line: lineOf(name) }))
121
+ const columns = collect(COLUMN_PATTERNS, text).map((name) => ({ name, line: lineOf(name) }))
122
+ const guesses = collect(GUESS_PATTERNS, text)
123
+ .filter((name) => !attributes.some((ref) => ref.name === name))
124
+ .map((name) => ({ name, line: lineOf(name) }))
125
+ const entities = [...new Set([...text.matchAll(WEBAPI_PATTERN)].map((m) => m[1]))]
126
+ const guids = [...new Set([...text.matchAll(GUID_PATTERN)].map((m) => m[0].toLowerCase()))]
127
+ const urls = [...new Set([...text.matchAll(URL_PATTERN)].map((m) => m[0]))]
128
+ const optionValues = [...new Set([...text.matchAll(OPTION_PATTERN)].map((m) => Number(m[1])))]
129
+ return { attributes, columns, guesses, entities, guids, urls, optionValues }
130
+ }
131
+
132
+ /** Function names a diff takes away — the ones whose removal breaks a form handler. */
133
+ export function extractRemovedFunctions(removedLines) {
134
+ const text = removedLines.map((entry) => entry.text).join('\n')
135
+ return collect(FUNCTION_PATTERNS, text)
136
+ }
137
+
138
+ /** Pick the resource a changed file most likely is, and say when it is a guess. */
139
+ async function mapWebResource(client, path, signal) {
140
+ const base = basename(path)
141
+ if (!base) return { resourceName: null, candidates: [], certain: false }
142
+ const page = await client.getPaged('webresourceset', {
143
+ $select: 'name',
144
+ $filter: `contains(name,'${odataString(base)}')`,
145
+ }, 100, signal)
146
+ const names = page.records.map((row) => row.name).filter(Boolean)
147
+ if (names.length === 1) return { resourceName: names[0], candidates: names, certain: true }
148
+ if (!names.length) return { resourceName: null, candidates: [], certain: false }
149
+ // Several resources share the basename: prefer one whose path suffix matches
150
+ // the repository path, otherwise leave the choice to a human.
151
+ const suffix = String(path).replace(/^\/+/, '')
152
+ const suffixMatch = names.filter((name) => suffix.endsWith(name) || name.endsWith(suffix))
153
+ if (suffixMatch.length === 1) return { resourceName: suffixMatch[0], candidates: names, certain: true }
154
+ return { resourceName: suffixMatch[0] ?? names[0], candidates: names, certain: false }
155
+ }
156
+
157
+ /** Forms whose formxml binds this library, verified against the exact attribute. */
158
+ async function findFormConsumers(client, resourceName, signal) {
159
+ const page = await client.getPaged('systemforms', {
160
+ $select: 'name,type,objecttypecode,formxml',
161
+ $filter: `contains(formxml,'${odataString(resourceName)}')`,
162
+ }, 200, signal)
163
+ return page.records.filter((row) =>
164
+ String(row.formxml ?? '').includes(`libraryName="${resourceName}"`)
165
+ || String(row.formxml ?? '').includes(`libraryName='${resourceName}'`))
166
+ }
167
+
168
+ /** Field names present on one form, plus the handlers it declares. */
169
+ export function describeForm(formxml) {
170
+ const xml = String(formxml ?? '')
171
+ const fields = new Set([...xml.matchAll(/datafieldname="([^"]+)"/gi)].map((m) => m[1]))
172
+ const handlers = [...xml.matchAll(/<Handler\b[^>]*>/gi)].map((m) => {
173
+ const functionName = m[0].match(/functionName="([^"]+)"/i)?.[1] ?? ''
174
+ const libraryName = m[0].match(/libraryName="([^"]+)"/i)?.[1] ?? ''
175
+ return { functionName, libraryName }
176
+ })
177
+ return { fields, handlers }
178
+ }
179
+
180
+ function attributeOnEntity(client, entity, attribute, signal) {
181
+ return client.getPaged(`EntityDefinitions(LogicalName='${odataString(entity)}')/Attributes`, {
182
+ $select: 'LogicalName,AttributeType',
183
+ $filter: `LogicalName eq '${odataString(attribute)}'`,
184
+ }, 1, signal).then((page) => page.records[0] ?? null).catch(() => null)
185
+ }
186
+
187
+ async function entityNames(client, signal) {
188
+ const names = new Set()
189
+ try {
190
+ const page = await client.getPaged('EntityDefinitions', { $select: 'LogicalName' }, 3000, signal)
191
+ for (const row of page.records) if (row.LogicalName) names.add(String(row.LogicalName).toLowerCase())
192
+ } catch {
193
+ // A tenant that refuses the metadata listing keeps this check silent.
194
+ }
195
+ return names
196
+ }
197
+
198
+ function resolveTargets(config, requested) {
199
+ const keys = Object.keys(config.environments)
200
+ const wanted = Array.isArray(requested) && requested.length ? requested : keys
201
+ const unknown = wanted.filter((key) => !config.environments[key])
202
+ if (unknown.length) {
203
+ throw new D365Error(`未知环境:${unknown.join(', ')}`, {
204
+ code: 'unknown-environment',
205
+ hint: `可用环境:${keys.join(', ')}`,
206
+ })
207
+ }
208
+ return wanted
209
+ }
210
+
211
+ /**
212
+ * Analyze one merge request against the configured environments.
213
+ *
214
+ * @param config - loaded plugin config.
215
+ * @param request - `{ mergeRequest, project?, targets? }`.
216
+ * @returns a report object; findings are limited to lines the MR changed.
217
+ */
218
+ /** Drop human items from `from` on that repeat an earlier object + question. */
219
+ function dropDuplicateHuman(human, from) {
220
+ const seen = new Set(human.slice(0, from).map((item) => `${item.object}|${item.question}`))
221
+ const kept = []
222
+ for (let index = from; index < human.length; index += 1) {
223
+ const item = human[index]
224
+ const key = `${item.object}|${item.question}`
225
+ if (seen.has(key)) continue
226
+ seen.add(key)
227
+ kept.push(item)
228
+ }
229
+ human.length = from
230
+ human.push(...kept)
231
+ }
232
+
233
+ export async function analyzeMergeRequest(config, request, signal) {
234
+ const merge = await fetchMergeRequest(config, request, signal)
235
+ const targetKeys = resolveTargets(config, request?.targets)
236
+
237
+ const files = []
238
+ const findings = []
239
+ const human = []
240
+
241
+ let humanId = 0
242
+ const askHuman = (entry) => {
243
+ humanId += 1
244
+ human.push({ id: `HC-${String(humanId).padStart(2, '0')}`, ...entry })
245
+ }
246
+
247
+ const codeChanges = merge.changes.filter((change) => CODE_EXTENSIONS.has(extension(change.newPath)))
248
+ for (const change of merge.changes) {
249
+ if (CODE_EXTENSIONS.has(extension(change.newPath)) || isCsharpPath(change.newPath)) continue
250
+ files.push({ path: change.newPath, kind: 'other', note: '非脚本文件,本次不做静态检查' })
251
+ }
252
+
253
+ for (const change of codeChanges) {
254
+ const { added, removed } = parseDiff(change.diff)
255
+ const refs = extractReferences(added)
256
+ const removedFunctions = extractRemovedFunctions(removed)
257
+ const fileReport = {
258
+ path: change.newPath,
259
+ kind: change.deletedFile ? 'deleted' : change.renamedFile ? 'renamed' : change.newFile ? 'added' : 'modified',
260
+ addedLines: added.length,
261
+ removedLines: removed.length,
262
+ references: refs,
263
+ removedFunctions,
264
+ targets: [],
265
+ }
266
+
267
+ if (!added.length && !removed.length) {
268
+ files.push(fileReport)
269
+ continue
270
+ }
271
+
272
+ for (const targetKey of targetKeys) {
273
+ const env = config.environments[targetKey]
274
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
275
+ const targetReport = { target: targetKey, resourceName: null, candidates: [], certain: false, forms: [], findings: 0 }
276
+
277
+ try {
278
+ const mapping = await mapWebResource(client, change.newPath, signal)
279
+ Object.assign(targetReport, mapping)
280
+ if (!mapping.resourceName) {
281
+ findings.push({
282
+ severity: 'warning',
283
+ target: targetKey,
284
+ file: change.newPath,
285
+ line: null,
286
+ message: `在 ${targetKey} 找不到文件名匹配的 Web 资源(${basename(change.newPath)})`,
287
+ })
288
+ askHuman({
289
+ object: change.newPath,
290
+ question: `这个文件在 ${targetKey} 对应哪个 Web 资源?`,
291
+ evidence: `按文件名 '${basename(change.newPath)}' 搜索,没有命中。`,
292
+ howToCheck: '在解决方案里找到该脚本的资源名,或在仓库里确认构建后的文件名。',
293
+ impact: '映射不上就无法检查表单引用,改动的运行时影响未知。',
294
+ })
295
+ fileReport.targets.push(targetReport)
296
+ continue
297
+ }
298
+ if (!mapping.certain) {
299
+ askHuman({
300
+ object: change.newPath,
301
+ question: `这个文件对应 ${mapping.resourceName} 吗?候选:${mapping.candidates.join('、')}`,
302
+ evidence: '按文件名匹配到多个资源,已按路径后缀选了最接近的一个。',
303
+ howToCheck: '核对仓库路径与环境里的 Web 资源名。',
304
+ impact: '映射错了,后面的检查对象就错了。',
305
+ })
306
+ }
307
+
308
+ const forms = await findFormConsumers(client, mapping.resourceName, signal)
309
+ targetReport.forms = forms.map((form) => ({
310
+ name: form.name,
311
+ type: form.type,
312
+ entity: form.objecttypecode,
313
+ }))
314
+
315
+ if (change.deletedFile && forms.length) {
316
+ findings.push({
317
+ severity: 'blocker',
318
+ target: targetKey,
319
+ file: change.newPath,
320
+ line: null,
321
+ message: `删除了 Web 资源 ${mapping.resourceName},${targetKey} 里仍有 ${forms.length} 张表单引用它`,
322
+ })
323
+ }
324
+
325
+ // One library is usually bound to several forms, and a field only has to
326
+ // exist on the forms that actually reach the changed line. Collect per
327
+ // field and decide once: missing everywhere is a blocker, missing on
328
+ // some of the forms is a warning, not a blocker per form.
329
+ const fieldUsage = new Map()
330
+ let entities = null
331
+ for (const form of forms) {
332
+ const { fields, handlers } = describeForm(form.formxml)
333
+ const entity = String(form.objecttypecode ?? '')
334
+
335
+ for (const ref of refs.attributes) {
336
+ const entry = fieldUsage.get(ref.name) ?? { name: ref.name, line: ref.line, present: [], missing: [] }
337
+ if (fields.has(ref.name)) entry.present.push(form.name)
338
+ else entry.missing.push({ form: form.name, entity })
339
+ fieldUsage.set(ref.name, entry)
340
+ }
341
+
342
+ for (const removedName of removedFunctions) {
343
+ const hit = handlers.some((handler) => handler.functionName.split('.').pop() === removedName)
344
+ if (!hit) continue
345
+ findings.push({
346
+ severity: 'blocker',
347
+ target: targetKey,
348
+ file: change.newPath,
349
+ line: null,
350
+ message: `删除了函数 ${removedName},但 ${targetKey} 的表单「${form.name}」仍把它注册为事件处理函数`,
351
+ })
352
+ }
353
+ }
354
+
355
+ for (const entry of fieldUsage.values()) {
356
+ if (!entry.missing.length) continue
357
+ if (entry.present.length) {
358
+ findings.push({
359
+ severity: 'warning',
360
+ target: targetKey,
361
+ file: change.newPath,
362
+ line: entry.line,
363
+ message: `改动的代码读取了 ${entry.name}:${targetKey} 上 ${entry.present.length} 张表单有这个字段,另有 ${entry.missing.length} 张没有(${entry.missing.map((item) => item.form).slice(0, 3).join('、')}${entry.missing.length > 3 ? '…' : ''})——确认脚本在这些表单上不会走到该分支`,
364
+ })
365
+ continue
366
+ }
367
+ findings.push({
368
+ severity: 'blocker',
369
+ target: targetKey,
370
+ file: change.newPath,
371
+ line: entry.line,
372
+ message: `改动的代码读取了 ${entry.name},但 ${targetKey} 上引用该库的 ${entry.missing.length} 张表单都没有这个字段`,
373
+ })
374
+ if (!entities) entities = await entityNames(client, signal)
375
+ for (const { entity } of entry.missing) {
376
+ if (!entity || entities.has(entity.toLowerCase())) continue
377
+ askHuman({
378
+ object: `${entity}.${entry.name}`,
379
+ question: `表 ${entity} 在 ${targetKey} 不存在,确认实体名是否改过`,
380
+ evidence: 'EntityDefinitions 里没有该实体。',
381
+ howToCheck: '检查实体逻辑名与解决方案是否包含该表。',
382
+ impact: '字段取值必然为 null,脚本会抛错。',
383
+ })
384
+ }
385
+ }
386
+
387
+ for (const ref of refs.columns ?? []) {
388
+ askHuman({
389
+ object: ref.name,
390
+ question: `确认 ${ref.name} 是视图/子网格列,还是表单字段`,
391
+ evidence: `改动行用 columns.get('${ref.name}') 取值;这个 API 读的是列,列不在表单上是正常的。`,
392
+ howToCheck: '如果是表单字段,应改用 getAttribute;如果是列,确认视图或子网格在目标环境也有这一列。',
393
+ impact: '列缺失时取值返回 null,不会报错,但界面会出现空白格。',
394
+ })
395
+ }
396
+
397
+ for (const ref of refs.guesses ?? []) {
398
+ askHuman({
399
+ object: ref.name,
400
+ question: `确认 ${ref.name} 在 ${targetKey} 里指的是字段、列还是别的键`,
401
+ evidence: '改动行以带前缀的字符串下标取值,静态分析无法判断它是元数据还是普通对象键。',
402
+ howToCheck: '在脚本上下文里看这个对象是什么;如果是表单字段,改用 getAttribute 以便下次能自动检查。',
403
+ impact: '判断不了就可能漏掉一个真实的字段缺失。',
404
+ })
405
+ }
406
+
407
+ // Entity names used through the Web API must exist in the target.
408
+ if (refs.entities.length) {
409
+ if (!entities) entities = await entityNames(client, signal)
410
+ for (const name of refs.entities) {
411
+ if (entities.has(name.toLowerCase())) continue
412
+ findings.push({
413
+ severity: 'blocker',
414
+ target: targetKey,
415
+ file: change.newPath,
416
+ line: null,
417
+ message: `改动的代码调用了 Xrm.WebApi 的 ${name},${targetKey} 里没有这个实体`,
418
+ })
419
+ }
420
+ }
421
+
422
+ for (const url of refs.urls) {
423
+ findings.push({
424
+ severity: 'warning',
425
+ target: targetKey,
426
+ file: change.newPath,
427
+ line: null,
428
+ message: `硬编码地址 ${url}`,
429
+ })
430
+ }
431
+
432
+ for (const guid of refs.guids) {
433
+ askHuman({
434
+ object: guid,
435
+ question: `确认 ${guid} 在 ${targetKey} 里指的是同一条记录`,
436
+ evidence: '代码里出现硬编码 GUID,静态检查无法判断它属于哪张表。',
437
+ howToCheck: '在源码里找到它的用途(配置记录 / 团队 / 队列 / 视图),再到目标环境按表查。',
438
+ impact: '记录不存在时运行时返回空或抛错。',
439
+ })
440
+ }
441
+
442
+ if (refs.optionValues.length) {
443
+ askHuman({
444
+ object: refs.optionValues.join(', '),
445
+ question: `确认这些选项值在 ${targetKey} 的选项集里存在`,
446
+ evidence: '代码用数字与选项值比较,静态检查无法可靠判断是哪个字段。',
447
+ howToCheck: '定位比较语句对应的字段,再到目标环境查该选项集的值域。',
448
+ impact: '值不存在时分支永远不进入,功能静默失效。',
449
+ })
450
+ }
451
+
452
+ targetReport.findings = findings.filter((entry) => entry.target === targetKey).length
453
+ } catch (error) {
454
+ findings.push({
455
+ severity: 'warning',
456
+ target: targetKey,
457
+ file: change.newPath,
458
+ line: null,
459
+ message: `检查失败:${error.message}`,
460
+ })
461
+ }
462
+ fileReport.targets.push(targetReport)
463
+ }
464
+ files.push(fileReport)
465
+ }
466
+
467
+ for (const change of merge.changes) {
468
+ if (!isCsharpPath(change.newPath)) continue
469
+ const { added, removed } = parseDiff(change.diff)
470
+ if (!added.length && !removed.length) {
471
+ files.push({ path: change.newPath, kind: 'csharp', addedLines: 0, removedLines: 0, targets: [] })
472
+ continue
473
+ }
474
+ const beforeHuman = human.length
475
+ const result = await analyzeCsharpFile({
476
+ change: { newPath: change.newPath, added, removed },
477
+ config,
478
+ targetKeys,
479
+ signal,
480
+ askHuman,
481
+ })
482
+ files.push(result.file)
483
+ findings.push(...result.findings)
484
+ dropDuplicateHuman(human, beforeHuman)
485
+ }
486
+
487
+ human.forEach((item, index) => {
488
+ item.id = `HC-${String(index + 1).padStart(2, '0')}`
489
+ })
490
+
491
+ return {
492
+ project: merge.project,
493
+ iid: merge.iid,
494
+ title: merge.title,
495
+ webUrl: merge.webUrl,
496
+ sourceBranch: merge.sourceBranch,
497
+ targetBranch: merge.targetBranch,
498
+ targets: targetKeys,
499
+ files,
500
+ findings,
501
+ human,
502
+ }
503
+ }
504
+
505
+ /** Write the markdown report beside the other artifacts and return its path. */
506
+ export async function writeCodeCheckReport(config, report) {
507
+ const directory = join(dataRoot(config), 'reports')
508
+ await mkdir(directory, { recursive: true })
509
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
510
+ const path = join(directory, `code-check-${report.iid}-${stamp}.md`)
511
+ const lines = []
512
+ lines.push(`# 代码改动影响检查 · MR !${report.iid}`, '')
513
+ lines.push(`- 项目:${report.project}`)
514
+ lines.push(`- 标题:${report.title || '(无标题)'}`)
515
+ lines.push(`- 分支:${report.sourceBranch} → ${report.targetBranch}`)
516
+ lines.push(`- 目标环境:${report.targets.join('、')}`)
517
+ lines.push('')
518
+ lines.push('## 改动的文件', '')
519
+ for (const file of report.files) {
520
+ lines.push(`- ${file.path}(${file.kind}):+${file.addedLines ?? 0} / -${file.removedLines ?? 0}`)
521
+ }
522
+ lines.push('', '## 阻断与警告', '')
523
+ if (!report.findings.length) lines.push('没有发现元数据层面的阻断项。')
524
+ for (const finding of report.findings) {
525
+ const where = finding.line ? `:${finding.line}` : ''
526
+ lines.push(`- [${finding.severity}] ${finding.target} · ${finding.file}${where} — ${finding.message}`)
527
+ }
528
+ lines.push('', '## 需要人工确认', '')
529
+ if (!report.human.length) lines.push('没有需要人工确认的条目。')
530
+ for (const item of report.human) {
531
+ lines.push(`### ${item.id} ${item.object}`, '')
532
+ lines.push(`- 问题:${item.question}`)
533
+ lines.push(`- 证据:${item.evidence}`)
534
+ lines.push(`- 怎么确认:${item.howToCheck}`)
535
+ lines.push(`- 影响:${item.impact}`)
536
+ lines.push('')
537
+ }
538
+ await writeFile(path, `${lines.join('\n')}\n`, 'utf8')
539
+ return path
540
+ }
541
+
542
+ /** Load the config the plugin was given, then analyze. */
543
+ export async function runCodeCheck(rawConfig, request, signal) {
544
+ const config = await loadConfig(rawConfig)
545
+ const report = await analyzeMergeRequest(config, request, signal)
546
+ const reportPath = await writeCodeCheckReport(config, report)
547
+ await enforceRetention(config, { target: 'reports' })
548
+ return { report, reportPath }
549
+ }