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
@@ -1,493 +1,493 @@
1
- /**
2
- * C# side of `d365_code_check`: turn a plugin source change into checks against
3
- * a target environment.
4
- *
5
- * The source tells us *what* changed (classes, images, literals); the
6
- * environment tells us *what is registered* (assembly, plugin types, steps,
7
- * images, filtering attributes). Nothing here can prove the DLL in the
8
- * environment matches the commit — that is what the human list is for.
9
- */
10
-
11
- import { DataverseClient, getPagedWithSelectFallback } from './client.js'
12
- import { readAll } from './refs.js'
13
- import { odataString } from './resources.js'
14
-
15
- const CS_PATH = /\.(cs|csproj|projitems|csproj\.user)$/i
16
-
17
- /** True for files this module knows how to read. */
18
- export function isCsharpPath(path) {
19
- return CS_PATH.test(String(path ?? ''))
20
- }
21
-
22
- const CLASS_PATTERN = /^\s*(?:public|internal|private|protected)?\s*(?:sealed\s+|static\s+|partial\s+|abstract\s+)*class\s+([A-Za-z_]\w*)\s*(?::\s*([^{]+))?/
23
- const IMAGE_PATTERN = /\b(Pre|Post)EntityImages\s*\[\s*"([^"]+)"\s*\]/g
24
- const LITERAL_PATTERN = /"([a-z][a-z0-9]{1,7}_[a-z0-9_]{2,})"/g
25
- const SANDBOX_PATTERN = /\b(System\.IO|System\.Net|System\.Threading|Thread\.Sleep|HttpClient|WebClient|File\.|Directory\.)\b/
26
- const ASSEMBLY_NAME_PATTERN = /<AssemblyName>\s*([^<\s]+)\s*<\/AssemblyName>/
27
- const ASSEMBLY_VERSION_PATTERN = /<Assembly(?:Informational)?Version>\s*([^<\s]+)\s*<\//
28
- const REFERENCE_PATTERN = /<Reference\s+Include="([^",]+)/
29
- const SECURE_CONFIG_PATTERN = /\b(secureConfig|unsecureConfig|SecureConfig|UnsecureConfig)\b/
30
- const CTOR_PATTERN = /(?:public|internal)\s+([A-Za-z_]\w*)\s*\(([^)]*)\)\s*(?::\s*base\([^)]*\))?\s*\{/g
31
-
32
- /**
33
- * Read the facts we need out of the added lines of a diff.
34
- * @param lines - `[{ line, text }]` as produced by `parseDiff`.
35
- */
36
- export function extractCsharpFacts(lines) {
37
- const classes = []
38
- const images = []
39
- const literals = new Map()
40
- const sandbox = []
41
- const references = []
42
- let assemblyName = null
43
- let assemblyVersion = null
44
- let secureConfig = false
45
-
46
- for (const { line, text } of lines) {
47
- const classMatch = CLASS_PATTERN.exec(text)
48
- if (classMatch) {
49
- const bases = String(classMatch[2] ?? '').split(',').map((item) => item.trim())
50
- classes.push({ name: classMatch[1], plugin: bases.some((item) => item.endsWith('IPlugin')), line })
51
- }
52
-
53
- IMAGE_PATTERN.lastIndex = 0
54
- let imageMatch
55
- while ((imageMatch = IMAGE_PATTERN.exec(text))) {
56
- images.push({ kind: imageMatch[1] === 'Pre' ? 'pre' : 'post', name: imageMatch[2], line })
57
- }
58
-
59
- LITERAL_PATTERN.lastIndex = 0
60
- let literalMatch
61
- while ((literalMatch = LITERAL_PATTERN.exec(text))) {
62
- if (!literals.has(literalMatch[1])) literals.set(literalMatch[1], line)
63
- }
64
-
65
- const sandboxMatch = SANDBOX_PATTERN.exec(text)
66
- if (sandboxMatch) sandbox.push({ api: sandboxMatch[1], line })
67
-
68
- const nameMatch = ASSEMBLY_NAME_PATTERN.exec(text)
69
- if (nameMatch) assemblyName = nameMatch[1]
70
- const versionMatch = ASSEMBLY_VERSION_PATTERN.exec(text)
71
- if (versionMatch) assemblyVersion = versionMatch[1]
72
-
73
- const refMatch = REFERENCE_PATTERN.exec(text)
74
- if (refMatch) references.push({ name: refMatch[1], line })
75
-
76
- if (SECURE_CONFIG_PATTERN.test(text)) secureConfig = true
77
- }
78
-
79
- CTOR_PATTERN.lastIndex = 0
80
- let ctor
81
- const text = lines.map((item) => item.text).join('\n')
82
- while ((ctor = CTOR_PATTERN.exec(text))) {
83
- const params = ctor[2]
84
- const strings = params.match(/\bstring\b/g)?.length ?? 0
85
- if (strings >= 2) secureConfig = true
86
- }
87
-
88
- return {
89
- classes,
90
- images: dedupe(images, (item) => `${item.kind}:${item.name}`),
91
- literals: [...literals.entries()].map(([value, line]) => ({ value, line })),
92
- sandbox,
93
- references,
94
- assemblyName,
95
- assemblyVersion,
96
- secureConfig,
97
- }
98
- }
99
-
100
- function dedupe(items, keyOf) {
101
- const seen = new Set()
102
- const out = []
103
- for (const item of items) {
104
- const key = keyOf(item)
105
- if (seen.has(key)) continue
106
- seen.add(key)
107
- out.push(item)
108
- }
109
- return out
110
- }
111
-
112
- /**
113
- * Guess the assembly a file belongs to: explicit `<AssemblyName>`, then the
114
- * innermost dotted folder above the file (that is the project folder in this
115
- * repository), then nothing.
116
- */
117
- export function guessAssembly(path, facts) {
118
- if (facts.assemblyName) return { name: facts.assemblyName.replace(/\.dll$/i, ''), source: 'csproj' }
119
- const segments = String(path ?? '').split('/').slice(0, -1)
120
- for (let index = segments.length - 1; index >= 0; index -= 1) {
121
- const segment = segments[index]
122
- if (/^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/.test(segment)) {
123
- return { name: segment, source: 'path' }
124
- }
125
- }
126
- return { name: null, source: 'none' }
127
- }
128
-
129
- async function listAssemblies(client, signal) {
130
- return readAll(client, 'pluginassemblies', {
131
- $select: 'pluginassemblyid,name,version,isolationmode,ismanaged',
132
- }, 500, signal)
133
- }
134
-
135
- function matchAssembly(assemblies, name) {
136
- const wanted = String(name).toLowerCase().replace(/\.dll$/, '')
137
- const exact = assemblies.find((item) => String(item.name).toLowerCase().replace(/\.dll$/, '') === wanted)
138
- if (exact) return { assembly: exact, certain: true, candidates: [exact.name] }
139
- const similar = assemblies
140
- .filter((item) => {
141
- const current = String(item.name).toLowerCase().replace(/\.dll$/, '')
142
- return current.includes(wanted.slice(-12)) || wanted.includes(current.slice(-12))
143
- })
144
- .map((item) => item.name)
145
- // One near miss is worth checking against; several are a coin flip, and a
146
- // wrong guess would validate the wrong types and steps. Report those for a
147
- // human instead of silently picking one.
148
- if (similar.length === 1) {
149
- const only = assemblies.find((item) => item.name === similar[0]) ?? null
150
- return { assembly: only, certain: false, candidates: similar }
151
- }
152
- return { assembly: null, certain: false, candidates: similar }
153
- }
154
-
155
- async function listTypes(client, assemblyId, signal) {
156
- return readAll(client, 'plugintypes', {
157
- $select: 'plugintypeid,typename,friendlyname,_pluginassemblyid_value',
158
- $filter: `_pluginassemblyid_value eq ${assemblyId}`,
159
- }, 500, signal)
160
- }
161
-
162
- async function listSteps(client, typeId, signal) {
163
- // `primaryobjecttypecode` lives on the message filter, not on the step, so
164
- // expand through `sdkmessagefilterid`; fall back to a plain read when the
165
- // environment rejects the expand.
166
- try {
167
- return await readAll(client, 'sdkmessageprocessingsteps', {
168
- $select: 'sdkmessageprocessingstepid,name,stage,mode,rank,filteringattributes,statecode,_sdkmessagefilterid_value,_sdkmessageid_value',
169
- $expand: 'sdkmessagefilterid($select=primaryobjecttypecode),sdkmessageid($select=name)',
170
- $filter: `_eventhandler_value eq ${typeId}`,
171
- }, 200, signal)
172
- } catch (error) {
173
- if (error?.status !== 400) throw error
174
- return readAll(client, 'sdkmessageprocessingsteps', {
175
- $select: 'sdkmessageprocessingstepid,name,stage,mode,rank,filteringattributes,statecode',
176
- $filter: `_eventhandler_value eq ${typeId}`,
177
- }, 200, signal)
178
- }
179
- }
180
-
181
- async function listImages(client, stepId, signal) {
182
- return readAll(client, 'sdkmessageprocessingstepimages', {
183
- $select: 'name,imagetype,attributes',
184
- $filter: `_sdkmessageprocessingstepid_value eq ${stepId}`,
185
- }, 50, signal)
186
- }
187
-
188
- async function attributeExists(client, entity, attribute, signal, cache) {
189
- const key = `${entity}.${attribute}`.toLowerCase()
190
- if (cache.has(key)) return cache.get(key)
191
- let exists = false
192
- try {
193
- const result = await getPagedWithSelectFallback(client, `EntityDefinitions(LogicalName='${odataString(entity)}')/Attributes`, {
194
- $select: 'LogicalName',
195
- $filter: `LogicalName eq '${odataString(attribute)}'`,
196
- }, 1, signal)
197
- exists = (result?.page?.records?.length ?? 0) > 0
198
- } catch {
199
- exists = false
200
- }
201
- cache.set(key, exists)
202
- return exists
203
- }
204
-
205
- async function entityNames(client, signal) {
206
- const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
207
- $select: 'LogicalName',
208
- }, 5000, signal)
209
- return new Set((result?.page?.records ?? []).map((row) => String(row.LogicalName).toLowerCase()))
210
- }
211
-
212
- /**
213
- * Check one changed C# file against every target environment.
214
- * @returns `{ file, findings, human }` — callers merge these into the report.
215
- */
216
- export async function analyzeCsharpFile({ change, config, targetKeys, signal, askHuman }) {
217
- const facts = extractCsharpFacts(change.added ?? [])
218
- const file = {
219
- path: change.newPath,
220
- kind: 'csharp',
221
- addedLines: change.added?.length ?? 0,
222
- removedLines: change.removed?.length ?? 0,
223
- facts: {
224
- classes: facts.classes.map((item) => item.name),
225
- images: facts.images.map((item) => `${item.kind}:${item.name}`),
226
- literals: facts.literals.map((item) => item.value),
227
- assemblyVersion: facts.assemblyVersion,
228
- },
229
- targets: [],
230
- }
231
- const findings = []
232
-
233
- const isBuildFile = /\.(csproj|projitems|csproj\.user)$/i.test(change.newPath)
234
- if (isBuildFile) {
235
- for (const reference of facts.references.slice(0, 10)) {
236
- findings.push({
237
- severity: 'warning',
238
- target: targetKeys[0] ?? '—',
239
- file: change.newPath,
240
- line: reference.line,
241
- message: `工程文件引用了外部程序集 ${reference.name},确认它随程序集部署且被沙箱允许`,
242
- })
243
- }
244
- file.note = '工程文件,只做引用提示'
245
- return { file, findings }
246
- }
247
-
248
- const guess = guessAssembly(change.newPath, facts)
249
- if (!guess.name) {
250
- askHuman({
251
- object: change.newPath,
252
- question: '这个文件属于哪个插件程序集?',
253
- evidence: '路径里没有像程序集名的段落,文件里也没有 AssemblyName。',
254
- howToCheck: '看它所在 csproj 的 AssemblyName,或环境里 pluginassemblies 的名字。',
255
- impact: '映射不上就无法核对程序集版本、类型、步骤与镜像。',
256
- })
257
- return { file, findings }
258
- }
259
-
260
- for (const targetKey of targetKeys) {
261
- const env = config.environments[targetKey]
262
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
263
- const targetReport = { target: targetKey, assembly: null, version: null, certain: false, candidates: [], types: [] }
264
- try {
265
- const assemblies = await listAssemblies(client, signal)
266
- const matched = matchAssembly(assemblies, guess.name)
267
- if (!matched.assembly) {
268
- findings.push({
269
- severity: 'warning',
270
- target: targetKey,
271
- file: change.newPath,
272
- line: null,
273
- message: matched.candidates.length
274
- ? `目标环境没有名为 ${guess.name} 的程序集;相近的有 ${matched.candidates.join('、')},需要确认文件属于哪一个`
275
- : `目标环境没有名为 ${guess.name} 的程序集,无法自动核对类型与步骤`,
276
- })
277
- askHuman({
278
- object: change.newPath,
279
- question: `这个文件最终打进哪个程序集?${targetKey} 里没有 ${guess.name}`,
280
- evidence: `按路径推断工程名 ${guess.name}${matched.candidates.length ? `,环境里相近的程序集:${matched.candidates.join('、')}` : ',环境里没有相近名字的程序集'}。`,
281
- howToCheck: '看它所属 csproj 的 AssemblyName,以及该工程被哪些插件程序集引用(共享工程不会单独部署)。',
282
- impact: '映射不上就核对不了程序集版本、类型、步骤与镜像。',
283
- })
284
- file.targets.push(targetReport)
285
- continue
286
- }
287
- if (!matched.certain) {
288
- askHuman({
289
- object: change.newPath,
290
- question: `这个文件属于 ${matched.assembly.name} 吗?候选:${matched.candidates.join('、')}`,
291
- evidence: '按路径推断的程序集名没有精确命中,按包含关系选了最接近的。',
292
- howToCheck: '核对 csproj 的 AssemblyName 与环境的插件程序集名。',
293
- impact: '映射错了,后面的类型/步骤核对对象就错了。',
294
- })
295
- }
296
-
297
- targetReport.assembly = matched.assembly.name
298
- targetReport.version = matched.assembly.version ?? null
299
- targetReport.certain = matched.certain
300
- targetReport.candidates = matched.candidates
301
-
302
- const compilerVersion = facts.assemblyVersion
303
- if (compilerVersion && matched.assembly.version && compilerVersion !== matched.assembly.version) {
304
- findings.push({
305
- severity: 'warning',
306
- target: targetKey,
307
- file: change.newPath,
308
- line: null,
309
- message: `源码里的版本 ${compilerVersion} 与环境里的程序集版本 ${matched.assembly.version} 不一致,入库的是否为编译产物需要确认`,
310
- })
311
- }
312
-
313
- const types = await listTypes(client, matched.assembly.pluginassemblyid, signal)
314
- const typeIndex = new Map(types.map((type) => [String(type.typename).toLowerCase(), type]))
315
- const classNames = facts.classes.filter((item) => item.plugin).map((item) => item.name)
316
- const basename = change.newPath.split('/').pop().replace(/\.cs$/i, '')
317
- if (!classNames.length && /Plugin$/.test(basename)) classNames.push(basename)
318
-
319
- const attributeCache = new Map()
320
- let entities = null
321
-
322
- for (const className of dedupe(classNames.map((name) => ({ name })), (item) => item.name)) {
323
- const match = types.find((type) => {
324
- const typename = String(type.typename).toLowerCase()
325
- return typename === className.name.toLowerCase() || typename.endsWith(`.${className.name.toLowerCase()}`)
326
- })
327
- const typeEntry = { name: className.name, exists: Boolean(match), steps: [] }
328
- targetReport.types.push(typeEntry)
329
-
330
- if (!match) {
331
- findings.push({
332
- severity: 'blocker',
333
- target: targetKey,
334
- file: change.newPath,
335
- line: null,
336
- message: `目标环境没有插件类型 ${className.name},类改了但程序集里没有这个类型`,
337
- })
338
- continue
339
- }
340
-
341
- const steps = await listSteps(client, match.plugintypeid, signal)
342
- if (!steps.length) {
343
- findings.push({
344
- severity: 'warning',
345
- target: targetKey,
346
- file: change.newPath,
347
- line: null,
348
- message: `插件类型 ${className.name} 在目标环境没有任何步骤,改动不会被触发`,
349
- })
350
- }
351
-
352
- // Two steps on the same message+stage run in rank order; when ranks are
353
- // equal (or both default) the order is not something to rely on.
354
- const stageGroups = new Map()
355
- for (const step of steps) {
356
- const message = String(step.sdkmessageid?.name ?? '').toLowerCase()
357
- const key = `${message}|${step.stage}`
358
- if (!stageGroups.has(key)) stageGroups.set(key, [])
359
- stageGroups.get(key).push(step)
360
- }
361
- for (const [key, group] of stageGroups) {
362
- if (group.length < 2) continue
363
- const ranks = group.map((step) => Number(step.rank ?? 0))
364
- const duplicated = new Set(ranks).size !== ranks.length
365
- const [message, stage] = key.split('|')
366
- findings.push({
367
- severity: 'warning',
368
- target: targetKey,
369
- file: change.newPath,
370
- line: null,
371
- message: `${message} / stage ${stage} 上有 ${group.length} 个步骤(rank ${ranks.join('、')})${duplicated ? ',rank 重复时执行顺序不确定' : ',按 rank 从小到大的顺序执行'}`,
372
- })
373
- }
374
-
375
- for (const step of steps.slice(0, 20)) {
376
- const images = await listImages(client, step.sdkmessageprocessingstepid, signal)
377
- typeEntry.steps.push({
378
- name: step.name,
379
- stage: step.stage,
380
- mode: step.mode,
381
- filteringAttributes: step.filteringattributes ?? '',
382
- images: images.map((image) => image.name),
383
- })
384
-
385
- const message = String(step.sdkmessageid?.name ?? '').toLowerCase()
386
- for (const wanted of facts.images) {
387
- // A Create step has no pre-image by definition; code that reads
388
- // PreEntityImages only on update is fine there.
389
- if (wanted.kind === 'pre' && message === 'create') continue
390
- const wantedType = wanted.kind === 'pre' ? 0 : 1
391
- const hit = images.some((image) => Number(image.imagetype) === wantedType
392
- && String(image.name).toLowerCase() === wanted.name.toLowerCase())
393
- if (hit) continue
394
- findings.push({
395
- severity: 'blocker',
396
- target: targetKey,
397
- file: change.newPath,
398
- line: wanted.line,
399
- message: `代码读取了 ${wanted.kind === 'pre' ? 'Pre' : 'Post'}EntityImages["${wanted.name}"],但步骤「${step.name}」没有注册同名镜像`,
400
- })
401
- }
402
-
403
- const entity = String(step.sdkmessagefilterid?.primaryobjecttypecode ?? step.primaryobjecttypecode ?? '')
404
- const filtering = String(step.filteringattributes ?? '').split(',').map((item) => item.trim()).filter(Boolean)
405
- if (entity && entity !== 'none' && filtering.length) {
406
- for (const attribute of filtering) {
407
- if (await attributeExists(client, entity, attribute, signal, attributeCache)) continue
408
- findings.push({
409
- severity: 'blocker',
410
- target: targetKey,
411
- file: change.newPath,
412
- line: null,
413
- message: `步骤「${step.name}」的过滤属性 ${attribute} 在表 ${entity} 上不存在,步骤不会按预期触发`,
414
- })
415
- }
416
- }
417
- }
418
- }
419
-
420
- // Metadata literals in code (entity / field / global option set names).
421
- const literals = facts.literals.slice(0, 25)
422
- if (literals.length) {
423
- if (!entities) entities = await entityNames(client, signal)
424
- const entityHints = literals
425
- .map((item) => item.value)
426
- .filter((value) => entities.has(value.toLowerCase()))
427
- .slice(0, 3)
428
-
429
- for (const literal of literals) {
430
- if (entities.has(literal.value.toLowerCase())) continue
431
- let fieldHit = false
432
- for (const entity of entityHints) {
433
- if (await attributeExists(client, entity, literal.value, signal, attributeCache)) {
434
- fieldHit = true
435
- break
436
- }
437
- }
438
- if (fieldHit) {
439
- targetReport.types.push({ name: `${literal.value}(字段)`, exists: true, steps: [] })
440
- continue
441
- }
442
- askHuman({
443
- object: literal.value,
444
- question: `确认 ${literal.value} 在 ${targetKey} 是否存在(表/字段/全局选项集)`,
445
- evidence: `代码第 ${literal.line} 行出现这个字面量;元数据里没有同名的表${entityHints.length ? `,在 ${entityHints.join('、')} 上也没有同名字段` : ''}。`,
446
- howToCheck: '确认它是字段、表还是全局选项集,再在目标环境核对;若属于别的表,需要在代码里看出所属实体。',
447
- impact: '名字拼错或目标环境没有该元数据时,插件运行到这一句会抛错或永远走不到。',
448
- })
449
- }
450
- }
451
-
452
- if (facts.secureConfig) {
453
- askHuman({
454
- object: `${matched.assembly.name} 的 secure configuration`,
455
- question: `确认 ${targetKey} 里该插件步骤的 secure configuration 已按这套代码配置`,
456
- evidence: '代码用到了 secure/unsecure 配置;Dataverse Web API 不返回 secureconfiguration 的内容,无法自动比对。',
457
- howToCheck: '在目标环境的插件注册工具里打开对应步骤,核对密钥版本与内容。',
458
- impact: '配置缺失或密钥版本不对时,解密会失败,而失败可能被吞掉(只写 tracing)。',
459
- })
460
- }
461
-
462
- for (const sandbox of facts.sandbox.slice(0, 5)) {
463
- findings.push({
464
- severity: 'warning',
465
- target: targetKey,
466
- file: change.newPath,
467
- line: sandbox.line,
468
- message: `代码用了 ${sandbox.api},在沙箱隔离模式下可能被禁止`,
469
- })
470
- }
471
- for (const reference of facts.references.slice(0, 10)) {
472
- findings.push({
473
- severity: 'warning',
474
- target: targetKey,
475
- file: change.newPath,
476
- line: reference.line,
477
- message: `引用了外部程序集 ${reference.name},确认它已随程序集部署且被沙箱允许`,
478
- })
479
- }
480
- } catch (error) {
481
- findings.push({
482
- severity: 'warning',
483
- target: targetKey,
484
- file: change.newPath,
485
- line: null,
486
- message: `C# 检查失败:${error.message}`,
487
- })
488
- }
489
- file.targets.push(targetReport)
490
- }
491
-
492
- return { file, findings }
493
- }
1
+ /**
2
+ * C# side of `d365_code_check`: turn a plugin source change into checks against
3
+ * a target environment.
4
+ *
5
+ * The source tells us *what* changed (classes, images, literals); the
6
+ * environment tells us *what is registered* (assembly, plugin types, steps,
7
+ * images, filtering attributes). Nothing here can prove the DLL in the
8
+ * environment matches the commit — that is what the human list is for.
9
+ */
10
+
11
+ import { DataverseClient, getPagedWithSelectFallback } from './client.js'
12
+ import { readAll } from './refs.js'
13
+ import { odataString } from './resources.js'
14
+
15
+ const CS_PATH = /\.(cs|csproj|projitems|csproj\.user)$/i
16
+
17
+ /** True for files this module knows how to read. */
18
+ export function isCsharpPath(path) {
19
+ return CS_PATH.test(String(path ?? ''))
20
+ }
21
+
22
+ const CLASS_PATTERN = /^\s*(?:public|internal|private|protected)?\s*(?:sealed\s+|static\s+|partial\s+|abstract\s+)*class\s+([A-Za-z_]\w*)\s*(?::\s*([^{]+))?/
23
+ const IMAGE_PATTERN = /\b(Pre|Post)EntityImages\s*\[\s*"([^"]+)"\s*\]/g
24
+ const LITERAL_PATTERN = /"([a-z][a-z0-9]{1,7}_[a-z0-9_]{2,})"/g
25
+ const SANDBOX_PATTERN = /\b(System\.IO|System\.Net|System\.Threading|Thread\.Sleep|HttpClient|WebClient|File\.|Directory\.)\b/
26
+ const ASSEMBLY_NAME_PATTERN = /<AssemblyName>\s*([^<\s]+)\s*<\/AssemblyName>/
27
+ const ASSEMBLY_VERSION_PATTERN = /<Assembly(?:Informational)?Version>\s*([^<\s]+)\s*<\//
28
+ const REFERENCE_PATTERN = /<Reference\s+Include="([^",]+)/
29
+ const SECURE_CONFIG_PATTERN = /\b(secureConfig|unsecureConfig|SecureConfig|UnsecureConfig)\b/
30
+ const CTOR_PATTERN = /(?:public|internal)\s+([A-Za-z_]\w*)\s*\(([^)]*)\)\s*(?::\s*base\([^)]*\))?\s*\{/g
31
+
32
+ /**
33
+ * Read the facts we need out of the added lines of a diff.
34
+ * @param lines - `[{ line, text }]` as produced by `parseDiff`.
35
+ */
36
+ export function extractCsharpFacts(lines) {
37
+ const classes = []
38
+ const images = []
39
+ const literals = new Map()
40
+ const sandbox = []
41
+ const references = []
42
+ let assemblyName = null
43
+ let assemblyVersion = null
44
+ let secureConfig = false
45
+
46
+ for (const { line, text } of lines) {
47
+ const classMatch = CLASS_PATTERN.exec(text)
48
+ if (classMatch) {
49
+ const bases = String(classMatch[2] ?? '').split(',').map((item) => item.trim())
50
+ classes.push({ name: classMatch[1], plugin: bases.some((item) => item.endsWith('IPlugin')), line })
51
+ }
52
+
53
+ IMAGE_PATTERN.lastIndex = 0
54
+ let imageMatch
55
+ while ((imageMatch = IMAGE_PATTERN.exec(text))) {
56
+ images.push({ kind: imageMatch[1] === 'Pre' ? 'pre' : 'post', name: imageMatch[2], line })
57
+ }
58
+
59
+ LITERAL_PATTERN.lastIndex = 0
60
+ let literalMatch
61
+ while ((literalMatch = LITERAL_PATTERN.exec(text))) {
62
+ if (!literals.has(literalMatch[1])) literals.set(literalMatch[1], line)
63
+ }
64
+
65
+ const sandboxMatch = SANDBOX_PATTERN.exec(text)
66
+ if (sandboxMatch) sandbox.push({ api: sandboxMatch[1], line })
67
+
68
+ const nameMatch = ASSEMBLY_NAME_PATTERN.exec(text)
69
+ if (nameMatch) assemblyName = nameMatch[1]
70
+ const versionMatch = ASSEMBLY_VERSION_PATTERN.exec(text)
71
+ if (versionMatch) assemblyVersion = versionMatch[1]
72
+
73
+ const refMatch = REFERENCE_PATTERN.exec(text)
74
+ if (refMatch) references.push({ name: refMatch[1], line })
75
+
76
+ if (SECURE_CONFIG_PATTERN.test(text)) secureConfig = true
77
+ }
78
+
79
+ CTOR_PATTERN.lastIndex = 0
80
+ let ctor
81
+ const text = lines.map((item) => item.text).join('\n')
82
+ while ((ctor = CTOR_PATTERN.exec(text))) {
83
+ const params = ctor[2]
84
+ const strings = params.match(/\bstring\b/g)?.length ?? 0
85
+ if (strings >= 2) secureConfig = true
86
+ }
87
+
88
+ return {
89
+ classes,
90
+ images: dedupe(images, (item) => `${item.kind}:${item.name}`),
91
+ literals: [...literals.entries()].map(([value, line]) => ({ value, line })),
92
+ sandbox,
93
+ references,
94
+ assemblyName,
95
+ assemblyVersion,
96
+ secureConfig,
97
+ }
98
+ }
99
+
100
+ function dedupe(items, keyOf) {
101
+ const seen = new Set()
102
+ const out = []
103
+ for (const item of items) {
104
+ const key = keyOf(item)
105
+ if (seen.has(key)) continue
106
+ seen.add(key)
107
+ out.push(item)
108
+ }
109
+ return out
110
+ }
111
+
112
+ /**
113
+ * Guess the assembly a file belongs to: explicit `<AssemblyName>`, then the
114
+ * innermost dotted folder above the file (that is the project folder in this
115
+ * repository), then nothing.
116
+ */
117
+ export function guessAssembly(path, facts) {
118
+ if (facts.assemblyName) return { name: facts.assemblyName.replace(/\.dll$/i, ''), source: 'csproj' }
119
+ const segments = String(path ?? '').split('/').slice(0, -1)
120
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
121
+ const segment = segments[index]
122
+ if (/^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/.test(segment)) {
123
+ return { name: segment, source: 'path' }
124
+ }
125
+ }
126
+ return { name: null, source: 'none' }
127
+ }
128
+
129
+ async function listAssemblies(client, signal) {
130
+ return readAll(client, 'pluginassemblies', {
131
+ $select: 'pluginassemblyid,name,version,isolationmode,ismanaged',
132
+ }, 500, signal)
133
+ }
134
+
135
+ function matchAssembly(assemblies, name) {
136
+ const wanted = String(name).toLowerCase().replace(/\.dll$/, '')
137
+ const exact = assemblies.find((item) => String(item.name).toLowerCase().replace(/\.dll$/, '') === wanted)
138
+ if (exact) return { assembly: exact, certain: true, candidates: [exact.name] }
139
+ const similar = assemblies
140
+ .filter((item) => {
141
+ const current = String(item.name).toLowerCase().replace(/\.dll$/, '')
142
+ return current.includes(wanted.slice(-12)) || wanted.includes(current.slice(-12))
143
+ })
144
+ .map((item) => item.name)
145
+ // One near miss is worth checking against; several are a coin flip, and a
146
+ // wrong guess would validate the wrong types and steps. Report those for a
147
+ // human instead of silently picking one.
148
+ if (similar.length === 1) {
149
+ const only = assemblies.find((item) => item.name === similar[0]) ?? null
150
+ return { assembly: only, certain: false, candidates: similar }
151
+ }
152
+ return { assembly: null, certain: false, candidates: similar }
153
+ }
154
+
155
+ async function listTypes(client, assemblyId, signal) {
156
+ return readAll(client, 'plugintypes', {
157
+ $select: 'plugintypeid,typename,friendlyname,_pluginassemblyid_value',
158
+ $filter: `_pluginassemblyid_value eq ${assemblyId}`,
159
+ }, 500, signal)
160
+ }
161
+
162
+ async function listSteps(client, typeId, signal) {
163
+ // `primaryobjecttypecode` lives on the message filter, not on the step, so
164
+ // expand through `sdkmessagefilterid`; fall back to a plain read when the
165
+ // environment rejects the expand.
166
+ try {
167
+ return await readAll(client, 'sdkmessageprocessingsteps', {
168
+ $select: 'sdkmessageprocessingstepid,name,stage,mode,rank,filteringattributes,statecode,_sdkmessagefilterid_value,_sdkmessageid_value',
169
+ $expand: 'sdkmessagefilterid($select=primaryobjecttypecode),sdkmessageid($select=name)',
170
+ $filter: `_eventhandler_value eq ${typeId}`,
171
+ }, 200, signal)
172
+ } catch (error) {
173
+ if (error?.status !== 400) throw error
174
+ return readAll(client, 'sdkmessageprocessingsteps', {
175
+ $select: 'sdkmessageprocessingstepid,name,stage,mode,rank,filteringattributes,statecode',
176
+ $filter: `_eventhandler_value eq ${typeId}`,
177
+ }, 200, signal)
178
+ }
179
+ }
180
+
181
+ async function listImages(client, stepId, signal) {
182
+ return readAll(client, 'sdkmessageprocessingstepimages', {
183
+ $select: 'name,imagetype,attributes',
184
+ $filter: `_sdkmessageprocessingstepid_value eq ${stepId}`,
185
+ }, 50, signal)
186
+ }
187
+
188
+ async function attributeExists(client, entity, attribute, signal, cache) {
189
+ const key = `${entity}.${attribute}`.toLowerCase()
190
+ if (cache.has(key)) return cache.get(key)
191
+ let exists = false
192
+ try {
193
+ const result = await getPagedWithSelectFallback(client, `EntityDefinitions(LogicalName='${odataString(entity)}')/Attributes`, {
194
+ $select: 'LogicalName',
195
+ $filter: `LogicalName eq '${odataString(attribute)}'`,
196
+ }, 1, signal)
197
+ exists = (result?.page?.records?.length ?? 0) > 0
198
+ } catch {
199
+ exists = false
200
+ }
201
+ cache.set(key, exists)
202
+ return exists
203
+ }
204
+
205
+ async function entityNames(client, signal) {
206
+ const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
207
+ $select: 'LogicalName',
208
+ }, 5000, signal)
209
+ return new Set((result?.page?.records ?? []).map((row) => String(row.LogicalName).toLowerCase()))
210
+ }
211
+
212
+ /**
213
+ * Check one changed C# file against every target environment.
214
+ * @returns `{ file, findings, human }` — callers merge these into the report.
215
+ */
216
+ export async function analyzeCsharpFile({ change, config, targetKeys, signal, askHuman }) {
217
+ const facts = extractCsharpFacts(change.added ?? [])
218
+ const file = {
219
+ path: change.newPath,
220
+ kind: 'csharp',
221
+ addedLines: change.added?.length ?? 0,
222
+ removedLines: change.removed?.length ?? 0,
223
+ facts: {
224
+ classes: facts.classes.map((item) => item.name),
225
+ images: facts.images.map((item) => `${item.kind}:${item.name}`),
226
+ literals: facts.literals.map((item) => item.value),
227
+ assemblyVersion: facts.assemblyVersion,
228
+ },
229
+ targets: [],
230
+ }
231
+ const findings = []
232
+
233
+ const isBuildFile = /\.(csproj|projitems|csproj\.user)$/i.test(change.newPath)
234
+ if (isBuildFile) {
235
+ for (const reference of facts.references.slice(0, 10)) {
236
+ findings.push({
237
+ severity: 'warning',
238
+ target: targetKeys[0] ?? '—',
239
+ file: change.newPath,
240
+ line: reference.line,
241
+ message: `工程文件引用了外部程序集 ${reference.name},确认它随程序集部署且被沙箱允许`,
242
+ })
243
+ }
244
+ file.note = '工程文件,只做引用提示'
245
+ return { file, findings }
246
+ }
247
+
248
+ const guess = guessAssembly(change.newPath, facts)
249
+ if (!guess.name) {
250
+ askHuman({
251
+ object: change.newPath,
252
+ question: '这个文件属于哪个插件程序集?',
253
+ evidence: '路径里没有像程序集名的段落,文件里也没有 AssemblyName。',
254
+ howToCheck: '看它所在 csproj 的 AssemblyName,或环境里 pluginassemblies 的名字。',
255
+ impact: '映射不上就无法核对程序集版本、类型、步骤与镜像。',
256
+ })
257
+ return { file, findings }
258
+ }
259
+
260
+ for (const targetKey of targetKeys) {
261
+ const env = config.environments[targetKey]
262
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
263
+ const targetReport = { target: targetKey, assembly: null, version: null, certain: false, candidates: [], types: [] }
264
+ try {
265
+ const assemblies = await listAssemblies(client, signal)
266
+ const matched = matchAssembly(assemblies, guess.name)
267
+ if (!matched.assembly) {
268
+ findings.push({
269
+ severity: 'warning',
270
+ target: targetKey,
271
+ file: change.newPath,
272
+ line: null,
273
+ message: matched.candidates.length
274
+ ? `目标环境没有名为 ${guess.name} 的程序集;相近的有 ${matched.candidates.join('、')},需要确认文件属于哪一个`
275
+ : `目标环境没有名为 ${guess.name} 的程序集,无法自动核对类型与步骤`,
276
+ })
277
+ askHuman({
278
+ object: change.newPath,
279
+ question: `这个文件最终打进哪个程序集?${targetKey} 里没有 ${guess.name}`,
280
+ evidence: `按路径推断工程名 ${guess.name}${matched.candidates.length ? `,环境里相近的程序集:${matched.candidates.join('、')}` : ',环境里没有相近名字的程序集'}。`,
281
+ howToCheck: '看它所属 csproj 的 AssemblyName,以及该工程被哪些插件程序集引用(共享工程不会单独部署)。',
282
+ impact: '映射不上就核对不了程序集版本、类型、步骤与镜像。',
283
+ })
284
+ file.targets.push(targetReport)
285
+ continue
286
+ }
287
+ if (!matched.certain) {
288
+ askHuman({
289
+ object: change.newPath,
290
+ question: `这个文件属于 ${matched.assembly.name} 吗?候选:${matched.candidates.join('、')}`,
291
+ evidence: '按路径推断的程序集名没有精确命中,按包含关系选了最接近的。',
292
+ howToCheck: '核对 csproj 的 AssemblyName 与环境的插件程序集名。',
293
+ impact: '映射错了,后面的类型/步骤核对对象就错了。',
294
+ })
295
+ }
296
+
297
+ targetReport.assembly = matched.assembly.name
298
+ targetReport.version = matched.assembly.version ?? null
299
+ targetReport.certain = matched.certain
300
+ targetReport.candidates = matched.candidates
301
+
302
+ const compilerVersion = facts.assemblyVersion
303
+ if (compilerVersion && matched.assembly.version && compilerVersion !== matched.assembly.version) {
304
+ findings.push({
305
+ severity: 'warning',
306
+ target: targetKey,
307
+ file: change.newPath,
308
+ line: null,
309
+ message: `源码里的版本 ${compilerVersion} 与环境里的程序集版本 ${matched.assembly.version} 不一致,入库的是否为编译产物需要确认`,
310
+ })
311
+ }
312
+
313
+ const types = await listTypes(client, matched.assembly.pluginassemblyid, signal)
314
+ const typeIndex = new Map(types.map((type) => [String(type.typename).toLowerCase(), type]))
315
+ const classNames = facts.classes.filter((item) => item.plugin).map((item) => item.name)
316
+ const basename = change.newPath.split('/').pop().replace(/\.cs$/i, '')
317
+ if (!classNames.length && /Plugin$/.test(basename)) classNames.push(basename)
318
+
319
+ const attributeCache = new Map()
320
+ let entities = null
321
+
322
+ for (const className of dedupe(classNames.map((name) => ({ name })), (item) => item.name)) {
323
+ const match = types.find((type) => {
324
+ const typename = String(type.typename).toLowerCase()
325
+ return typename === className.name.toLowerCase() || typename.endsWith(`.${className.name.toLowerCase()}`)
326
+ })
327
+ const typeEntry = { name: className.name, exists: Boolean(match), steps: [] }
328
+ targetReport.types.push(typeEntry)
329
+
330
+ if (!match) {
331
+ findings.push({
332
+ severity: 'blocker',
333
+ target: targetKey,
334
+ file: change.newPath,
335
+ line: null,
336
+ message: `目标环境没有插件类型 ${className.name},类改了但程序集里没有这个类型`,
337
+ })
338
+ continue
339
+ }
340
+
341
+ const steps = await listSteps(client, match.plugintypeid, signal)
342
+ if (!steps.length) {
343
+ findings.push({
344
+ severity: 'warning',
345
+ target: targetKey,
346
+ file: change.newPath,
347
+ line: null,
348
+ message: `插件类型 ${className.name} 在目标环境没有任何步骤,改动不会被触发`,
349
+ })
350
+ }
351
+
352
+ // Two steps on the same message+stage run in rank order; when ranks are
353
+ // equal (or both default) the order is not something to rely on.
354
+ const stageGroups = new Map()
355
+ for (const step of steps) {
356
+ const message = String(step.sdkmessageid?.name ?? '').toLowerCase()
357
+ const key = `${message}|${step.stage}`
358
+ if (!stageGroups.has(key)) stageGroups.set(key, [])
359
+ stageGroups.get(key).push(step)
360
+ }
361
+ for (const [key, group] of stageGroups) {
362
+ if (group.length < 2) continue
363
+ const ranks = group.map((step) => Number(step.rank ?? 0))
364
+ const duplicated = new Set(ranks).size !== ranks.length
365
+ const [message, stage] = key.split('|')
366
+ findings.push({
367
+ severity: 'warning',
368
+ target: targetKey,
369
+ file: change.newPath,
370
+ line: null,
371
+ message: `${message} / stage ${stage} 上有 ${group.length} 个步骤(rank ${ranks.join('、')})${duplicated ? ',rank 重复时执行顺序不确定' : ',按 rank 从小到大的顺序执行'}`,
372
+ })
373
+ }
374
+
375
+ for (const step of steps.slice(0, 20)) {
376
+ const images = await listImages(client, step.sdkmessageprocessingstepid, signal)
377
+ typeEntry.steps.push({
378
+ name: step.name,
379
+ stage: step.stage,
380
+ mode: step.mode,
381
+ filteringAttributes: step.filteringattributes ?? '',
382
+ images: images.map((image) => image.name),
383
+ })
384
+
385
+ const message = String(step.sdkmessageid?.name ?? '').toLowerCase()
386
+ for (const wanted of facts.images) {
387
+ // A Create step has no pre-image by definition; code that reads
388
+ // PreEntityImages only on update is fine there.
389
+ if (wanted.kind === 'pre' && message === 'create') continue
390
+ const wantedType = wanted.kind === 'pre' ? 0 : 1
391
+ const hit = images.some((image) => Number(image.imagetype) === wantedType
392
+ && String(image.name).toLowerCase() === wanted.name.toLowerCase())
393
+ if (hit) continue
394
+ findings.push({
395
+ severity: 'blocker',
396
+ target: targetKey,
397
+ file: change.newPath,
398
+ line: wanted.line,
399
+ message: `代码读取了 ${wanted.kind === 'pre' ? 'Pre' : 'Post'}EntityImages["${wanted.name}"],但步骤「${step.name}」没有注册同名镜像`,
400
+ })
401
+ }
402
+
403
+ const entity = String(step.sdkmessagefilterid?.primaryobjecttypecode ?? step.primaryobjecttypecode ?? '')
404
+ const filtering = String(step.filteringattributes ?? '').split(',').map((item) => item.trim()).filter(Boolean)
405
+ if (entity && entity !== 'none' && filtering.length) {
406
+ for (const attribute of filtering) {
407
+ if (await attributeExists(client, entity, attribute, signal, attributeCache)) continue
408
+ findings.push({
409
+ severity: 'blocker',
410
+ target: targetKey,
411
+ file: change.newPath,
412
+ line: null,
413
+ message: `步骤「${step.name}」的过滤属性 ${attribute} 在表 ${entity} 上不存在,步骤不会按预期触发`,
414
+ })
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ // Metadata literals in code (entity / field / global option set names).
421
+ const literals = facts.literals.slice(0, 25)
422
+ if (literals.length) {
423
+ if (!entities) entities = await entityNames(client, signal)
424
+ const entityHints = literals
425
+ .map((item) => item.value)
426
+ .filter((value) => entities.has(value.toLowerCase()))
427
+ .slice(0, 3)
428
+
429
+ for (const literal of literals) {
430
+ if (entities.has(literal.value.toLowerCase())) continue
431
+ let fieldHit = false
432
+ for (const entity of entityHints) {
433
+ if (await attributeExists(client, entity, literal.value, signal, attributeCache)) {
434
+ fieldHit = true
435
+ break
436
+ }
437
+ }
438
+ if (fieldHit) {
439
+ targetReport.types.push({ name: `${literal.value}(字段)`, exists: true, steps: [] })
440
+ continue
441
+ }
442
+ askHuman({
443
+ object: literal.value,
444
+ question: `确认 ${literal.value} 在 ${targetKey} 是否存在(表/字段/全局选项集)`,
445
+ evidence: `代码第 ${literal.line} 行出现这个字面量;元数据里没有同名的表${entityHints.length ? `,在 ${entityHints.join('、')} 上也没有同名字段` : ''}。`,
446
+ howToCheck: '确认它是字段、表还是全局选项集,再在目标环境核对;若属于别的表,需要在代码里看出所属实体。',
447
+ impact: '名字拼错或目标环境没有该元数据时,插件运行到这一句会抛错或永远走不到。',
448
+ })
449
+ }
450
+ }
451
+
452
+ if (facts.secureConfig) {
453
+ askHuman({
454
+ object: `${matched.assembly.name} 的 secure configuration`,
455
+ question: `确认 ${targetKey} 里该插件步骤的 secure configuration 已按这套代码配置`,
456
+ evidence: '代码用到了 secure/unsecure 配置;Dataverse Web API 不返回 secureconfiguration 的内容,无法自动比对。',
457
+ howToCheck: '在目标环境的插件注册工具里打开对应步骤,核对密钥版本与内容。',
458
+ impact: '配置缺失或密钥版本不对时,解密会失败,而失败可能被吞掉(只写 tracing)。',
459
+ })
460
+ }
461
+
462
+ for (const sandbox of facts.sandbox.slice(0, 5)) {
463
+ findings.push({
464
+ severity: 'warning',
465
+ target: targetKey,
466
+ file: change.newPath,
467
+ line: sandbox.line,
468
+ message: `代码用了 ${sandbox.api},在沙箱隔离模式下可能被禁止`,
469
+ })
470
+ }
471
+ for (const reference of facts.references.slice(0, 10)) {
472
+ findings.push({
473
+ severity: 'warning',
474
+ target: targetKey,
475
+ file: change.newPath,
476
+ line: reference.line,
477
+ message: `引用了外部程序集 ${reference.name},确认它已随程序集部署且被沙箱允许`,
478
+ })
479
+ }
480
+ } catch (error) {
481
+ findings.push({
482
+ severity: 'warning',
483
+ target: targetKey,
484
+ file: change.newPath,
485
+ line: null,
486
+ message: `C# 检查失败:${error.message}`,
487
+ })
488
+ }
489
+ file.targets.push(targetReport)
490
+ }
491
+
492
+ return { file, findings }
493
+ }