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/contract.js CHANGED
@@ -1,583 +1,583 @@
1
- /**
2
- * Contract matrix and drift rules.
3
- *
4
- * The matrix answers "which registration does the code imply, and does it
5
- * exist in every environment". The drift engine answers "which of the implied
6
- * things are missing or older downstream" — with a direction: upstream (the
7
- * source of the change) to downstream (targets).
8
- *
9
- * Both run on live queries rather than snapshots: plugin registration is small
10
- * enough to read directly, and a stale snapshot would produce false drift.
11
- */
12
-
13
- import { D365Error } from './errors.js'
14
- import { makeClient } from './runtime.js'
15
- import { dataRoot } from './snapshot.js'
16
- import { mkdir, writeFile } from 'node:fs/promises'
17
- import { join } from 'node:path'
18
-
19
- /** Rules are data: id, severity, title and a predicate over contract data. */
20
- export const RULES = [
21
- {
22
- id: 'assembly-missing',
23
- severity: 'blocker',
24
- title: '下游环境缺少上游的自定义程序集',
25
- },
26
- {
27
- id: 'assembly-version-older',
28
- severity: 'warning',
29
- title: '下游程序集版本比上游旧',
30
- },
31
- {
32
- id: 'plugin-type-missing',
33
- severity: 'blocker',
34
- title: '下游环境缺少插件类型',
35
- },
36
- {
37
- id: 'plugin-step-missing',
38
- severity: 'blocker',
39
- title: '下游环境缺少插件步骤',
40
- },
41
- {
42
- id: 'option-value-missing',
43
- severity: 'warning',
44
- title: '下游选项集缺少上游的选项值',
45
- },
46
- {
47
- id: 'form-library-missing',
48
- severity: 'blocker',
49
- title: '表单引用的 Web 资源在环境里不存在',
50
- },
51
- {
52
- id: 'sitemap-missing',
53
- severity: 'warning',
54
- title: '下游缺少上游的自定义站点地图',
55
- },
56
- {
57
- id: 'handler-missing',
58
- severity: 'warning',
59
- title: '表单 Handler 指向的函数在脚本里找不到',
60
- },
61
- ]
62
-
63
- function normalizeAssembly(name) {
64
- return String(name ?? '').toLowerCase().replace(/\.dll$/, '')
65
- }
66
-
67
- function compareVersion(left, right) {
68
- const a = String(left ?? '0').split('.').map((part) => Number.parseInt(part, 10) || 0)
69
- const b = String(right ?? '0').split('.').map((part) => Number.parseInt(part, 10) || 0)
70
- for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
71
- const diff = (a[index] ?? 0) - (b[index] ?? 0)
72
- if (diff !== 0) return diff
73
- }
74
- return 0
75
- }
76
-
77
- async function readAll(client, path, params, signal) {
78
- const rows = []
79
- let next = client.buildUrl(path, params)
80
- let pages = 0
81
- while (next && pages < 200) {
82
- const page = await client.requestJson(next, { signal })
83
- rows.push(...(page?.value ?? []))
84
- next = page?.['@odata.nextLink'] ?? null
85
- pages += 1
86
- }
87
- return rows
88
- }
89
-
90
- /** Read the registration facts a contract needs from one environment. */
91
- export async function readContractFacts(client, signal, options = {}) {
92
- const assemblies = await readAll(client, 'pluginassemblies', {
93
- $select: 'pluginassemblyid,name,version,ismanaged,isolationmode',
94
- }, signal)
95
- const types = await readAll(client, 'plugintypes', {
96
- $select: 'plugintypeid,typename,_pluginassemblyid_value',
97
- }, signal)
98
- const steps = await readAll(client, 'sdkmessageprocessingsteps', {
99
- $select: 'sdkmessageprocessingstepid,_eventhandler_value,_sdkmessageid_value,stage,mode',
100
- }, signal)
101
- const messages = await readAll(client, 'sdkmessages', { $select: 'sdkmessageid,name' }, signal)
102
-
103
- const messageNames = new Map()
104
- for (const message of messages) messageNames.set(message.sdkmessageid, message.name)
105
-
106
- let forms = []
107
- let resources = []
108
- let sitemaps = []
109
- if (options.includeVisual !== false) {
110
- forms = await readAll(client, 'systemforms', { $select: 'name,objecttypecode,formxml' }, signal)
111
- resources = await readAll(client, 'webresourceset', { $select: 'name' }, signal)
112
- sitemaps = await readAll(client, 'sitemaps', { $select: 'sitemapname,ismanaged' }, signal)
113
- }
114
-
115
- const assemblyById = new Map()
116
- for (const assembly of assemblies) assemblyById.set(assembly.pluginassemblyid, assembly)
117
-
118
- const typeById = new Map()
119
- for (const type of types) {
120
- const assembly = assemblyById.get(type._pluginassemblyid_value)
121
- typeById.set(type.plugintypeid, {
122
- id: type.plugintypeid,
123
- name: type.typename,
124
- assembly: assembly?.name ?? null,
125
- assemblyId: type._pluginassemblyid_value ?? null,
126
- })
127
- }
128
-
129
- const stepsByType = new Map()
130
- for (const step of steps) {
131
- const type = typeById.get(step._eventhandler_value)
132
- if (!type) continue
133
- const message = messageNames.get(step._sdkmessageid_value) ?? 'unknown'
134
- const key = `${message}|${step.stage}|${step.mode}`
135
- if (!stepsByType.has(step._eventhandler_value)) stepsByType.set(step._eventhandler_value, new Set())
136
- stepsByType.get(step._eventhandler_value).add(key)
137
- }
138
-
139
- return { assemblies, types: [...typeById.values()], stepsByType, messageNames, forms, resources, sitemaps }
140
- }
141
-
142
- /** `libraryName="..."` values in a form definition, excluding embedded refs. */
143
- export function librariesFrom(formxml) {
144
- const found = new Set()
145
- const pattern = /libraryName="([^"]+)"/g
146
- let match = pattern.exec(String(formxml ?? ''))
147
- while (match) {
148
- const name = match[1]
149
- if (name && !name.startsWith('$webresource:')) found.add(name)
150
- match = pattern.exec(String(formxml ?? ''))
151
- }
152
- return found
153
- }
154
-
155
- /**
156
- * Visual-layer drift: a form that loads a script the environment does not have
157
- * breaks at runtime even though every metadata table looks fine.
158
- */
159
- export function compareVisualContract(upstream, downstreams) {
160
- const findings = []
161
- for (const downstream of downstreams) {
162
- const resources = new Set(downstream.facts.resources.map((item) => String(item.name).toLowerCase()))
163
- const reported = new Set()
164
- for (const form of downstream.facts.forms) {
165
- for (const library of librariesFrom(form.formxml)) {
166
- const key = library.toLowerCase()
167
- if (resources.has(key) || reported.has(key)) continue
168
- reported.add(key)
169
- findings.push(finding('form-library-missing', downstream.env, library,
170
- `表单「${form.name}」引用的库 ${library} 在 ${downstream.env} 不存在`,
171
- `objecttypecode ${form.objecttypecode ?? '—'}`))
172
- }
173
- }
174
- }
175
- const upstreamSitemaps = upstream.facts.sitemaps.filter((item) => item.ismanaged !== true)
176
- for (const downstream of downstreams) {
177
- const names = new Set(downstream.facts.sitemaps.map((item) => String(item.sitemapname)))
178
- for (const sitemap of upstreamSitemaps) {
179
- if (names.has(sitemap.sitemapname)) continue
180
- findings.push(finding('sitemap-missing', downstream.env, sitemap.sitemapname,
181
- `上游(${upstream.env})有自定义站点地图 ${sitemap.sitemapname},下游没有`))
182
- }
183
- }
184
- return findings
185
- }
186
-
187
- /** Normalised view: assembly name → version, type key → step keys. */
188
- export function indexContract(facts) {
189
- const assemblies = new Map()
190
- for (const assembly of facts.assemblies) {
191
- assemblies.set(normalizeAssembly(assembly.name), {
192
- name: assembly.name,
193
- version: assembly.version,
194
- ismanaged: assembly.ismanaged === true,
195
- })
196
- }
197
- const types = new Map()
198
- for (const type of facts.types) {
199
- const key = `${normalizeAssembly(type.assembly)}|${type.name}`
200
- types.set(key, {
201
- name: type.name,
202
- assembly: normalizeAssembly(type.assembly),
203
- steps: facts.stepsByType.get(type.id) ?? new Set(),
204
- })
205
- }
206
- return { assemblies, types }
207
- }
208
-
209
- function finding(rule, env, object, message, evidence) {
210
- const meta = RULES.find((item) => item.id === rule)
211
- return { rule, severity: meta?.severity ?? 'warning', env, object, message, evidence: evidence ?? '' }
212
- }
213
-
214
- /**
215
- * Compare the upstream contract with one or more downstream environments.
216
- * Only assemblies that are unmanaged upstream are treated as contract: managed
217
- * ones come from Microsoft or a managed solution and cannot be changed locally.
218
- */
219
- export function compareContracts(upstream, downstreams, options = {}) {
220
- const findings = []
221
- const rows = []
222
- const upstreamIndex = indexContract(upstream.facts)
223
-
224
- for (const downstream of downstreams) {
225
- const index = indexContract(downstream.facts)
226
- const counters = { assemblies: 0, types: 0, steps: 0 }
227
- rows.push({ env: downstream.env, assemblies: index.assemblies.size, types: index.types.size, steps: downstream.facts.stepsByType.size })
228
-
229
- for (const [key, assembly] of upstreamIndex.assemblies) {
230
- if (assembly.ismanaged && !options.includeManaged) continue
231
- const target = index.assemblies.get(key)
232
- if (!target) {
233
- counters.assemblies += 1
234
- findings.push(finding('assembly-missing', downstream.env, assembly.name,
235
- `上游(${upstream.env})有程序集 ${assembly.name},下游没有`))
236
- continue
237
- }
238
- if (compareVersion(target.version, assembly.version) < 0) {
239
- findings.push(finding('assembly-version-older', downstream.env, assembly.name,
240
- `${assembly.name}:上游 ${assembly.version} > 下游 ${target.version}`,
241
- `上游快照 ${upstream.env}`))
242
- }
243
- }
244
-
245
- for (const [key, type] of upstreamIndex.types) {
246
- const source = upstreamIndex.assemblies.get(type.assembly)
247
- if (source?.ismanaged && !options.includeManaged) continue
248
- const target = index.types.get(key)
249
- if (!target) {
250
- if (!index.assemblies.has(type.assembly)) continue
251
- counters.types += 1
252
- findings.push(finding('plugin-type-missing', downstream.env, type.name,
253
- `上游(${upstream.env})注册了 ${type.name},下游没有`, `程序集 ${type.assembly}`))
254
- continue
255
- }
256
- for (const stepKey of type.steps) {
257
- if (target.steps.has(stepKey)) continue
258
- counters.steps += 1
259
- const [message, stage, mode] = stepKey.split('|')
260
- findings.push(finding('plugin-step-missing', downstream.env, `${type.name} · ${message}`,
261
- `${type.name} 缺少 ${message} / stage ${stage} / mode ${mode} 的步骤`,
262
- `程序集 ${type.assembly}`))
263
- }
264
- }
265
- }
266
-
267
- return { findings, rows }
268
- }
269
-
270
- /** Read one global option set's values, for value drift. */
271
- export async function readOptionValues(client, name, signal) {
272
- const url = client.buildUrl(`GlobalOptionSetDefinitions(Name='${name.replace(/'/g, "''")}')/Microsoft.Dynamics.CRM.OptionSetMetadata`)
273
- const payload = await client.requestJson(url, { signal })
274
- const definition = payload?.OptionSet ?? payload
275
- const values = new Map()
276
- for (const option of definition?.Options ?? []) {
277
- const label = option?.Label?.UserLocalizedLabel?.Label ?? option?.Label?.LocalizedLabels?.[0]?.Label ?? ''
278
- values.set(String(option.Value), label)
279
- }
280
- return values
281
- }
282
-
283
- /** Compare option values between upstream and downstream for named sets. */
284
- export async function compareOptionValues(upstreamClient, downstreamClients, names, signal) {
285
- const findings = []
286
- for (const name of names) {
287
- const source = await readOptionValues(upstreamClient, name, signal).catch(() => null)
288
- if (!source) {
289
- findings.push(finding('option-value-missing', '—', name, `上游读不到选项集 ${name}`, null))
290
- continue
291
- }
292
- for (const { env, client } of downstreamClients) {
293
- const target = await readOptionValues(client, name, signal).catch(() => null)
294
- if (!target) {
295
- findings.push(finding('option-value-missing', env, name, `下游缺少选项集 ${name}`))
296
- continue
297
- }
298
- const missing = [...source.keys()].filter((value) => !target.has(value))
299
- const extra = [...target.keys()].filter((value) => !source.has(value))
300
- if (missing.length) {
301
- findings.push(finding('option-value-missing', env, name,
302
- `下游缺 ${missing.length} 个值:${missing.slice(0, 8).map((value) => `${value}=${source.get(value)}`).join(', ')}${missing.length > 8 ? ' …' : ''}`,
303
- `上游共 ${source.size} 个值,下游 ${target.size} 个`))
304
- }
305
- if (extra.length) {
306
- findings.push(finding('option-value-missing', env, name,
307
- `下游多 ${extra.length} 个上游没有的值:${extra.slice(0, 8).join(', ')}${extra.length > 8 ? ' …' : ''}`,
308
- '下游比上游新,确认是否已经回灌'))
309
- }
310
- }
311
- }
312
- return findings
313
- }
314
-
315
- /** Build clients for the environments a call targets. */
316
- export async function openContractClients(config, envKeys, signal) {
317
- const clients = new Map()
318
- for (const key of envKeys) {
319
- const env = config.environments[key]
320
- if (!env) throw new D365Error(`未知环境:${key}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
321
- clients.set(key, makeClient(env, config))
322
- }
323
- return clients
324
- }
325
-
326
- const HANDLER_PATTERN = /<Handler\b[^>]*functionName="([^"]+)"[^>]*libraryName="([^"]+)"/g
327
-
328
- /** Handlers a form xml points at: `{ functionName, libraryName }`. */
329
- export function handlersFrom(formxml) {
330
- const found = []
331
- const text = String(formxml ?? '')
332
- let match = HANDLER_PATTERN.exec(text)
333
- while (match) {
334
- found.push({ functionName: match[1], libraryName: match[2] })
335
- match = HANDLER_PATTERN.exec(text)
336
- }
337
- HANDLER_PATTERN.lastIndex = 0
338
- return found
339
- }
340
-
341
- async function readResourceContent(client, name, signal) {
342
- const url = client.buildUrl('webresourceset', {
343
- $select: 'content',
344
- $filter: `name eq '${String(name).replace(/'/g, "''")}'`,
345
- })
346
- const page = await client.requestJson(url, { signal })
347
- const row = page?.value?.[0]
348
- if (!row?.content) return null
349
- return Buffer.from(row.content, 'base64').toString('utf8')
350
- }
351
-
352
- // Publishers that belong to the platform itself. Everything else is treated as
353
- // project code, so a fresh install works against any organization without
354
- // knowing its publisher prefix up front.
355
- const PLATFORM_PREFIXES = new Set([
356
- 'adx', 'crm', 'dynamics', 'microsoft', 'msdyn', 'msft', 'system',
357
- ])
358
-
359
- function normalizePrefix(value) {
360
- const prefix = String(value ?? '').trim().toLowerCase()
361
- if (!prefix) return ''
362
- if (PLATFORM_PREFIXES.has(prefix.replace(/_+$/, ''))) return ''
363
- return prefix.endsWith('_') ? prefix : `${prefix}_`
364
- }
365
-
366
- /**
367
- * Work out the publisher prefixes that belong to the project, instead of asking
368
- * the user to hard-code them. Unmanaged solutions carry the project's own
369
- * publishers, so their `customizationprefix` values are exactly the prefixes
370
- * that script libraries and schema names use.
371
- *
372
- * A real organization also holds a few unmanaged solutions from partners and
373
- * samples (on one project: 1525 solutions from the project publisher, then 8,
374
- * 6 and a handful of ones from everybody else). Judging those libraries as
375
- * project code produced dozens of false "function missing" findings, because
376
- * vendor bundles are minified and their internal names are mangled. So only
377
- * the dominant prefixes are kept, and `handlerPrefixes` in the config can pin
378
- * the list exactly when the heuristic guesses wrong.
379
- */
380
- const PREFIX_SHARE = 0.2
381
- const MAX_PREFIXES = 5
382
-
383
- export async function detectHandlerPrefixes(client, signal) {
384
- let solutions = []
385
- try {
386
- solutions = await readAll(client, 'solutions', {
387
- $select: 'uniquename',
388
- $filter: 'ismanaged eq false',
389
- $expand: 'publisherid($select=customizationprefix)',
390
- }, signal)
391
- } catch {
392
- solutions = []
393
- }
394
-
395
- const counts = new Map()
396
- for (const row of solutions) {
397
- const prefix = normalizePrefix(row?.publisherid?.customizationprefix)
398
- if (prefix) counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
399
- }
400
-
401
- if (!counts.size) {
402
- try {
403
- const publishers = await readAll(client, 'publishers', {
404
- $select: 'customizationprefix',
405
- }, signal)
406
- for (const row of publishers) {
407
- const prefix = normalizePrefix(row?.customizationprefix)
408
- if (prefix) counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
409
- }
410
- } catch {
411
- // leave the list empty; the caller reports that nothing was checked
412
- }
413
- }
414
-
415
- const ranked = [...counts].sort((a, b) => b[1] - a[1])
416
- const top = ranked[0]?.[1] ?? 0
417
- const threshold = Math.max(2, Math.ceil(top * PREFIX_SHARE))
418
- const prefixes = new Set(ranked.filter(([, count]) => count >= threshold).map(([prefix]) => prefix))
419
- if (prefixes.size > MAX_PREFIXES) {
420
- prefixes.clear()
421
- for (const [prefix] of ranked.slice(0, MAX_PREFIXES)) prefixes.add(prefix)
422
- }
423
-
424
- // `new` is the prefix the customizer hands out by default; a project that
425
- // never created a publisher of its own still owns those libraries.
426
- prefixes.add('new_')
427
- return [...prefixes].sort()
428
- }
429
-
430
- /**
431
- * Check that every handler a form registers exists in the script it points at.
432
- * This needs the resource content, so it is opt-in and bounded: only the first
433
- * `libraryLimit` script libraries referenced by forms are downloaded.
434
- *
435
- * The check looks at *every* library the form loads, not just the handler's own
436
- * one: form event handlers resolve in the form's shared script scope, so
437
- * `PDS_UI.onSaveProduct` declared on `product.js` may well be provided by
438
- * `msdyn_productdeterminationservice_ui` that the same form also loads. Looking
439
- * only at the handler's library produced false positives for exactly that case.
440
- */
441
- export async function compareHandlers(clients, downstreams, signal, options = {}) {
442
- // SIT alone references ~156 distinct project libraries from forms, so a low
443
- // budget would silently turn later forms into "unreadable" warnings.
444
- const limit = Number.isFinite(options.libraryLimit) ? options.libraryLimit : 400
445
- // Only project libraries are worth judging: system forms attach handlers that
446
- // the platform injects itself (`absoluteurl_onchange`, `Main_Form_onload`),
447
- // and those never appear in the resource the form lists.
448
- const prefixes = (options.prefixes ?? []).map((item) => String(item).toLowerCase())
449
- const isProjectLibrary = (name) => prefixes.some((prefix) => String(name).toLowerCase().startsWith(prefix))
450
- const findings = []
451
- for (const downstream of downstreams) {
452
- // Per environment, not shared: the same missing handler in two environments
453
- // is two findings, and a shared set reported it only for the first one.
454
- const seen = new Set()
455
- const client = clients.get(downstream.env)
456
- const contents = new Map()
457
- let fetched = 0
458
- const load = async (name) => {
459
- const key = name.toLowerCase()
460
- if (contents.has(key)) return contents.get(key)
461
- if (fetched >= limit) return undefined
462
- fetched += 1
463
- let content = null
464
- try {
465
- content = await readResourceContent(client, name, signal)
466
- } catch {
467
- content = null
468
- }
469
- contents.set(key, content)
470
- return content
471
- }
472
-
473
- for (const form of downstream.facts.forms) {
474
- // Judge project handlers against the project libraries the same form
475
- // loads. Microsoft libraries are skipped on purpose: a handler in a
476
- // Microsoft library is platform-provided, and pulling every MS script in
477
- // burns the download budget until later forms read as "unknown".
478
- const libraries = [...librariesFrom(form.formxml)]
479
- .filter((name) => name.toLowerCase().endsWith('.js'))
480
- .filter((name) => isProjectLibrary(name))
481
- if (!libraries.length) continue
482
- const handlers = handlersFrom(form.formxml)
483
- .filter((handler) => handler.libraryName.toLowerCase().endsWith('.js'))
484
- .filter((handler) => isProjectLibrary(handler.libraryName))
485
- if (!handlers.length) continue
486
-
487
- // Resolve each handler against its own library first: that is one download
488
- // per distinct project library and answers almost every case. Only when the
489
- // name is missing there do we widen to the other project libraries the form
490
- // loads — the `PDS_UI` case, where a form-level name lives in a sibling lib.
491
- let unknown = 0
492
- const reportable = []
493
- for (const handler of handlers) {
494
- const own = await load(handler.libraryName)
495
- if (typeof own === 'string' && handlerLooksPresent(own.toLowerCase(), handler.functionName)) continue
496
- reportable.push(handler)
497
- }
498
- if (!reportable.length) continue
499
-
500
- const haystacks = []
501
- for (const library of libraries) {
502
- const content = await load(library)
503
- if (typeof content === 'string') haystacks.push(content.toLowerCase())
504
- else unknown += 1
505
- }
506
- if (!haystacks.length) continue
507
-
508
- for (const handler of reportable) {
509
- if (haystacks.some((haystack) => handlerLooksPresent(haystack, handler.functionName))) continue
510
- const key = `${handler.functionName}|${handler.libraryName}`.toLowerCase()
511
- if (seen.has(key)) continue
512
- seen.add(key)
513
- findings.push(finding('handler-missing', downstream.env, handler.functionName,
514
- `表单 Handler 指向的函数在它所在的脚本环境里找不到:${handler.functionName}`,
515
- unknown
516
- ? `表单「${form.name ?? '未命名'}」加载的 ${libraries.length} 个脚本里有 ${unknown} 个读不到内容(资源不存在或超出上限),无法完全确认;函数也可能由动态加载的脚本提供`
517
- : `表单「${form.name ?? '未命名'}」加载的 ${libraries.length} 个脚本里都没有这个名字(可能被改名、未部署,或由动态加载的脚本提供)`))
518
- }
519
- }
520
- }
521
- return findings
522
- }
523
-
524
- /**
525
- * Lenient presence check for a handler. Scripts are often minified or define
526
- * members through object literals (`LearnMoreDialog.OnLoad = ...`,
527
- * `OnLoad:function`), so we look for the full name, the last two segments and
528
- * the bare member name, case-insensitively.
529
- */
530
- export function handlerLooksPresent(haystack, functionName) {
531
- const parts = String(functionName).split('.').filter(Boolean)
532
- const candidates = [functionName, parts.slice(-2).join('.'), parts[parts.length - 1]]
533
- return candidates.some((candidate) => candidate && haystack.includes(candidate.toLowerCase()))
534
- }
535
-
536
- export async function writeContractReport(config, report) {
537
- const directory = join(dataRoot(config), 'reports')
538
- await mkdir(directory, { recursive: true })
539
- const stamp = new Date().toISOString().replace(/[:.]/g, '-')
540
- const jsonPath = join(directory, `contract-${stamp}.json`)
541
- await writeFile(jsonPath, JSON.stringify(report, null, 2), 'utf8')
542
- return jsonPath
543
- }
544
-
545
- export function renderContractMarkdown(report) {
546
- const lines = []
547
- lines.push(`# 契约矩阵 / 漂移:${report.upstream} → ${report.targets.join(', ')}`)
548
- lines.push('')
549
- lines.push(`生成时间:${report.generatedAt}`)
550
- lines.push('')
551
- if (report.trend) {
552
- lines.push(`对比上次(${report.trend.comparedTo}):新增 ${report.trend.added.length} 条,已消失 ${report.trend.fixed.length} 条。`)
553
- lines.push('')
554
- }
555
- if (report.waived) {
556
- lines.push(`已豁免 ${report.waived} 条。`)
557
- lines.push('')
558
- }
559
- lines.push('## 环境规模')
560
- lines.push('')
561
- lines.push('| 环境 | 程序集 | 插件类型 | 有步骤的类型 |')
562
- lines.push('| --- | --- | --- | --- |')
563
- for (const row of report.rows) {
564
- lines.push(`| ${row.env} | ${row.assemblies} | ${row.types} | ${row.steps} |`)
565
- }
566
- lines.push('')
567
- const bySeverity = { blocker: [], warning: [] }
568
- for (const item of report.findings) (bySeverity[item.severity] ?? bySeverity.warning).push(item)
569
- for (const [severity, title] of [['blocker', '阻断'], ['warning', '警告']]) {
570
- const items = bySeverity[severity]
571
- lines.push(`## ${title} ${items.length}`)
572
- lines.push('')
573
- if (!items.length) {
574
- lines.push('无。')
575
- } else {
576
- for (const item of items) {
577
- lines.push(`- [${item.env}] ${item.rule}:${item.message}${item.evidence ? `(${item.evidence})` : ''}`)
578
- }
579
- }
580
- lines.push('')
581
- }
582
- return lines.join('\n')
583
- }
1
+ /**
2
+ * Contract matrix and drift rules.
3
+ *
4
+ * The matrix answers "which registration does the code imply, and does it
5
+ * exist in every environment". The drift engine answers "which of the implied
6
+ * things are missing or older downstream" — with a direction: upstream (the
7
+ * source of the change) to downstream (targets).
8
+ *
9
+ * Both run on live queries rather than snapshots: plugin registration is small
10
+ * enough to read directly, and a stale snapshot would produce false drift.
11
+ */
12
+
13
+ import { D365Error } from './errors.js'
14
+ import { makeClient } from './runtime.js'
15
+ import { dataRoot } from './snapshot.js'
16
+ import { mkdir, writeFile } from 'node:fs/promises'
17
+ import { join } from 'node:path'
18
+
19
+ /** Rules are data: id, severity, title and a predicate over contract data. */
20
+ export const RULES = [
21
+ {
22
+ id: 'assembly-missing',
23
+ severity: 'blocker',
24
+ title: '下游环境缺少上游的自定义程序集',
25
+ },
26
+ {
27
+ id: 'assembly-version-older',
28
+ severity: 'warning',
29
+ title: '下游程序集版本比上游旧',
30
+ },
31
+ {
32
+ id: 'plugin-type-missing',
33
+ severity: 'blocker',
34
+ title: '下游环境缺少插件类型',
35
+ },
36
+ {
37
+ id: 'plugin-step-missing',
38
+ severity: 'blocker',
39
+ title: '下游环境缺少插件步骤',
40
+ },
41
+ {
42
+ id: 'option-value-missing',
43
+ severity: 'warning',
44
+ title: '下游选项集缺少上游的选项值',
45
+ },
46
+ {
47
+ id: 'form-library-missing',
48
+ severity: 'blocker',
49
+ title: '表单引用的 Web 资源在环境里不存在',
50
+ },
51
+ {
52
+ id: 'sitemap-missing',
53
+ severity: 'warning',
54
+ title: '下游缺少上游的自定义站点地图',
55
+ },
56
+ {
57
+ id: 'handler-missing',
58
+ severity: 'warning',
59
+ title: '表单 Handler 指向的函数在脚本里找不到',
60
+ },
61
+ ]
62
+
63
+ function normalizeAssembly(name) {
64
+ return String(name ?? '').toLowerCase().replace(/\.dll$/, '')
65
+ }
66
+
67
+ function compareVersion(left, right) {
68
+ const a = String(left ?? '0').split('.').map((part) => Number.parseInt(part, 10) || 0)
69
+ const b = String(right ?? '0').split('.').map((part) => Number.parseInt(part, 10) || 0)
70
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
71
+ const diff = (a[index] ?? 0) - (b[index] ?? 0)
72
+ if (diff !== 0) return diff
73
+ }
74
+ return 0
75
+ }
76
+
77
+ async function readAll(client, path, params, signal) {
78
+ const rows = []
79
+ let next = client.buildUrl(path, params)
80
+ let pages = 0
81
+ while (next && pages < 200) {
82
+ const page = await client.requestJson(next, { signal })
83
+ rows.push(...(page?.value ?? []))
84
+ next = page?.['@odata.nextLink'] ?? null
85
+ pages += 1
86
+ }
87
+ return rows
88
+ }
89
+
90
+ /** Read the registration facts a contract needs from one environment. */
91
+ export async function readContractFacts(client, signal, options = {}) {
92
+ const assemblies = await readAll(client, 'pluginassemblies', {
93
+ $select: 'pluginassemblyid,name,version,ismanaged,isolationmode',
94
+ }, signal)
95
+ const types = await readAll(client, 'plugintypes', {
96
+ $select: 'plugintypeid,typename,_pluginassemblyid_value',
97
+ }, signal)
98
+ const steps = await readAll(client, 'sdkmessageprocessingsteps', {
99
+ $select: 'sdkmessageprocessingstepid,_eventhandler_value,_sdkmessageid_value,stage,mode',
100
+ }, signal)
101
+ const messages = await readAll(client, 'sdkmessages', { $select: 'sdkmessageid,name' }, signal)
102
+
103
+ const messageNames = new Map()
104
+ for (const message of messages) messageNames.set(message.sdkmessageid, message.name)
105
+
106
+ let forms = []
107
+ let resources = []
108
+ let sitemaps = []
109
+ if (options.includeVisual !== false) {
110
+ forms = await readAll(client, 'systemforms', { $select: 'name,objecttypecode,formxml' }, signal)
111
+ resources = await readAll(client, 'webresourceset', { $select: 'name' }, signal)
112
+ sitemaps = await readAll(client, 'sitemaps', { $select: 'sitemapname,ismanaged' }, signal)
113
+ }
114
+
115
+ const assemblyById = new Map()
116
+ for (const assembly of assemblies) assemblyById.set(assembly.pluginassemblyid, assembly)
117
+
118
+ const typeById = new Map()
119
+ for (const type of types) {
120
+ const assembly = assemblyById.get(type._pluginassemblyid_value)
121
+ typeById.set(type.plugintypeid, {
122
+ id: type.plugintypeid,
123
+ name: type.typename,
124
+ assembly: assembly?.name ?? null,
125
+ assemblyId: type._pluginassemblyid_value ?? null,
126
+ })
127
+ }
128
+
129
+ const stepsByType = new Map()
130
+ for (const step of steps) {
131
+ const type = typeById.get(step._eventhandler_value)
132
+ if (!type) continue
133
+ const message = messageNames.get(step._sdkmessageid_value) ?? 'unknown'
134
+ const key = `${message}|${step.stage}|${step.mode}`
135
+ if (!stepsByType.has(step._eventhandler_value)) stepsByType.set(step._eventhandler_value, new Set())
136
+ stepsByType.get(step._eventhandler_value).add(key)
137
+ }
138
+
139
+ return { assemblies, types: [...typeById.values()], stepsByType, messageNames, forms, resources, sitemaps }
140
+ }
141
+
142
+ /** `libraryName="..."` values in a form definition, excluding embedded refs. */
143
+ export function librariesFrom(formxml) {
144
+ const found = new Set()
145
+ const pattern = /libraryName="([^"]+)"/g
146
+ let match = pattern.exec(String(formxml ?? ''))
147
+ while (match) {
148
+ const name = match[1]
149
+ if (name && !name.startsWith('$webresource:')) found.add(name)
150
+ match = pattern.exec(String(formxml ?? ''))
151
+ }
152
+ return found
153
+ }
154
+
155
+ /**
156
+ * Visual-layer drift: a form that loads a script the environment does not have
157
+ * breaks at runtime even though every metadata table looks fine.
158
+ */
159
+ export function compareVisualContract(upstream, downstreams) {
160
+ const findings = []
161
+ for (const downstream of downstreams) {
162
+ const resources = new Set(downstream.facts.resources.map((item) => String(item.name).toLowerCase()))
163
+ const reported = new Set()
164
+ for (const form of downstream.facts.forms) {
165
+ for (const library of librariesFrom(form.formxml)) {
166
+ const key = library.toLowerCase()
167
+ if (resources.has(key) || reported.has(key)) continue
168
+ reported.add(key)
169
+ findings.push(finding('form-library-missing', downstream.env, library,
170
+ `表单「${form.name}」引用的库 ${library} 在 ${downstream.env} 不存在`,
171
+ `objecttypecode ${form.objecttypecode ?? '—'}`))
172
+ }
173
+ }
174
+ }
175
+ const upstreamSitemaps = upstream.facts.sitemaps.filter((item) => item.ismanaged !== true)
176
+ for (const downstream of downstreams) {
177
+ const names = new Set(downstream.facts.sitemaps.map((item) => String(item.sitemapname)))
178
+ for (const sitemap of upstreamSitemaps) {
179
+ if (names.has(sitemap.sitemapname)) continue
180
+ findings.push(finding('sitemap-missing', downstream.env, sitemap.sitemapname,
181
+ `上游(${upstream.env})有自定义站点地图 ${sitemap.sitemapname},下游没有`))
182
+ }
183
+ }
184
+ return findings
185
+ }
186
+
187
+ /** Normalised view: assembly name → version, type key → step keys. */
188
+ export function indexContract(facts) {
189
+ const assemblies = new Map()
190
+ for (const assembly of facts.assemblies) {
191
+ assemblies.set(normalizeAssembly(assembly.name), {
192
+ name: assembly.name,
193
+ version: assembly.version,
194
+ ismanaged: assembly.ismanaged === true,
195
+ })
196
+ }
197
+ const types = new Map()
198
+ for (const type of facts.types) {
199
+ const key = `${normalizeAssembly(type.assembly)}|${type.name}`
200
+ types.set(key, {
201
+ name: type.name,
202
+ assembly: normalizeAssembly(type.assembly),
203
+ steps: facts.stepsByType.get(type.id) ?? new Set(),
204
+ })
205
+ }
206
+ return { assemblies, types }
207
+ }
208
+
209
+ function finding(rule, env, object, message, evidence) {
210
+ const meta = RULES.find((item) => item.id === rule)
211
+ return { rule, severity: meta?.severity ?? 'warning', env, object, message, evidence: evidence ?? '' }
212
+ }
213
+
214
+ /**
215
+ * Compare the upstream contract with one or more downstream environments.
216
+ * Only assemblies that are unmanaged upstream are treated as contract: managed
217
+ * ones come from Microsoft or a managed solution and cannot be changed locally.
218
+ */
219
+ export function compareContracts(upstream, downstreams, options = {}) {
220
+ const findings = []
221
+ const rows = []
222
+ const upstreamIndex = indexContract(upstream.facts)
223
+
224
+ for (const downstream of downstreams) {
225
+ const index = indexContract(downstream.facts)
226
+ const counters = { assemblies: 0, types: 0, steps: 0 }
227
+ rows.push({ env: downstream.env, assemblies: index.assemblies.size, types: index.types.size, steps: downstream.facts.stepsByType.size })
228
+
229
+ for (const [key, assembly] of upstreamIndex.assemblies) {
230
+ if (assembly.ismanaged && !options.includeManaged) continue
231
+ const target = index.assemblies.get(key)
232
+ if (!target) {
233
+ counters.assemblies += 1
234
+ findings.push(finding('assembly-missing', downstream.env, assembly.name,
235
+ `上游(${upstream.env})有程序集 ${assembly.name},下游没有`))
236
+ continue
237
+ }
238
+ if (compareVersion(target.version, assembly.version) < 0) {
239
+ findings.push(finding('assembly-version-older', downstream.env, assembly.name,
240
+ `${assembly.name}:上游 ${assembly.version} > 下游 ${target.version}`,
241
+ `上游快照 ${upstream.env}`))
242
+ }
243
+ }
244
+
245
+ for (const [key, type] of upstreamIndex.types) {
246
+ const source = upstreamIndex.assemblies.get(type.assembly)
247
+ if (source?.ismanaged && !options.includeManaged) continue
248
+ const target = index.types.get(key)
249
+ if (!target) {
250
+ if (!index.assemblies.has(type.assembly)) continue
251
+ counters.types += 1
252
+ findings.push(finding('plugin-type-missing', downstream.env, type.name,
253
+ `上游(${upstream.env})注册了 ${type.name},下游没有`, `程序集 ${type.assembly}`))
254
+ continue
255
+ }
256
+ for (const stepKey of type.steps) {
257
+ if (target.steps.has(stepKey)) continue
258
+ counters.steps += 1
259
+ const [message, stage, mode] = stepKey.split('|')
260
+ findings.push(finding('plugin-step-missing', downstream.env, `${type.name} · ${message}`,
261
+ `${type.name} 缺少 ${message} / stage ${stage} / mode ${mode} 的步骤`,
262
+ `程序集 ${type.assembly}`))
263
+ }
264
+ }
265
+ }
266
+
267
+ return { findings, rows }
268
+ }
269
+
270
+ /** Read one global option set's values, for value drift. */
271
+ export async function readOptionValues(client, name, signal) {
272
+ const url = client.buildUrl(`GlobalOptionSetDefinitions(Name='${name.replace(/'/g, "''")}')/Microsoft.Dynamics.CRM.OptionSetMetadata`)
273
+ const payload = await client.requestJson(url, { signal })
274
+ const definition = payload?.OptionSet ?? payload
275
+ const values = new Map()
276
+ for (const option of definition?.Options ?? []) {
277
+ const label = option?.Label?.UserLocalizedLabel?.Label ?? option?.Label?.LocalizedLabels?.[0]?.Label ?? ''
278
+ values.set(String(option.Value), label)
279
+ }
280
+ return values
281
+ }
282
+
283
+ /** Compare option values between upstream and downstream for named sets. */
284
+ export async function compareOptionValues(upstreamClient, downstreamClients, names, signal) {
285
+ const findings = []
286
+ for (const name of names) {
287
+ const source = await readOptionValues(upstreamClient, name, signal).catch(() => null)
288
+ if (!source) {
289
+ findings.push(finding('option-value-missing', '—', name, `上游读不到选项集 ${name}`, null))
290
+ continue
291
+ }
292
+ for (const { env, client } of downstreamClients) {
293
+ const target = await readOptionValues(client, name, signal).catch(() => null)
294
+ if (!target) {
295
+ findings.push(finding('option-value-missing', env, name, `下游缺少选项集 ${name}`))
296
+ continue
297
+ }
298
+ const missing = [...source.keys()].filter((value) => !target.has(value))
299
+ const extra = [...target.keys()].filter((value) => !source.has(value))
300
+ if (missing.length) {
301
+ findings.push(finding('option-value-missing', env, name,
302
+ `下游缺 ${missing.length} 个值:${missing.slice(0, 8).map((value) => `${value}=${source.get(value)}`).join(', ')}${missing.length > 8 ? ' …' : ''}`,
303
+ `上游共 ${source.size} 个值,下游 ${target.size} 个`))
304
+ }
305
+ if (extra.length) {
306
+ findings.push(finding('option-value-missing', env, name,
307
+ `下游多 ${extra.length} 个上游没有的值:${extra.slice(0, 8).join(', ')}${extra.length > 8 ? ' …' : ''}`,
308
+ '下游比上游新,确认是否已经回灌'))
309
+ }
310
+ }
311
+ }
312
+ return findings
313
+ }
314
+
315
+ /** Build clients for the environments a call targets. */
316
+ export async function openContractClients(config, envKeys, signal) {
317
+ const clients = new Map()
318
+ for (const key of envKeys) {
319
+ const env = config.environments[key]
320
+ if (!env) throw new D365Error(`未知环境:${key}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
321
+ clients.set(key, makeClient(env, config))
322
+ }
323
+ return clients
324
+ }
325
+
326
+ const HANDLER_PATTERN = /<Handler\b[^>]*functionName="([^"]+)"[^>]*libraryName="([^"]+)"/g
327
+
328
+ /** Handlers a form xml points at: `{ functionName, libraryName }`. */
329
+ export function handlersFrom(formxml) {
330
+ const found = []
331
+ const text = String(formxml ?? '')
332
+ let match = HANDLER_PATTERN.exec(text)
333
+ while (match) {
334
+ found.push({ functionName: match[1], libraryName: match[2] })
335
+ match = HANDLER_PATTERN.exec(text)
336
+ }
337
+ HANDLER_PATTERN.lastIndex = 0
338
+ return found
339
+ }
340
+
341
+ async function readResourceContent(client, name, signal) {
342
+ const url = client.buildUrl('webresourceset', {
343
+ $select: 'content',
344
+ $filter: `name eq '${String(name).replace(/'/g, "''")}'`,
345
+ })
346
+ const page = await client.requestJson(url, { signal })
347
+ const row = page?.value?.[0]
348
+ if (!row?.content) return null
349
+ return Buffer.from(row.content, 'base64').toString('utf8')
350
+ }
351
+
352
+ // Publishers that belong to the platform itself. Everything else is treated as
353
+ // project code, so a fresh install works against any organization without
354
+ // knowing its publisher prefix up front.
355
+ const PLATFORM_PREFIXES = new Set([
356
+ 'adx', 'crm', 'dynamics', 'microsoft', 'msdyn', 'msft', 'system',
357
+ ])
358
+
359
+ function normalizePrefix(value) {
360
+ const prefix = String(value ?? '').trim().toLowerCase()
361
+ if (!prefix) return ''
362
+ if (PLATFORM_PREFIXES.has(prefix.replace(/_+$/, ''))) return ''
363
+ return prefix.endsWith('_') ? prefix : `${prefix}_`
364
+ }
365
+
366
+ /**
367
+ * Work out the publisher prefixes that belong to the project, instead of asking
368
+ * the user to hard-code them. Unmanaged solutions carry the project's own
369
+ * publishers, so their `customizationprefix` values are exactly the prefixes
370
+ * that script libraries and schema names use.
371
+ *
372
+ * A real organization also holds a few unmanaged solutions from partners and
373
+ * samples (on one project: 1525 solutions from the project publisher, then 8,
374
+ * 6 and a handful of ones from everybody else). Judging those libraries as
375
+ * project code produced dozens of false "function missing" findings, because
376
+ * vendor bundles are minified and their internal names are mangled. So only
377
+ * the dominant prefixes are kept, and `handlerPrefixes` in the config can pin
378
+ * the list exactly when the heuristic guesses wrong.
379
+ */
380
+ const PREFIX_SHARE = 0.2
381
+ const MAX_PREFIXES = 5
382
+
383
+ export async function detectHandlerPrefixes(client, signal) {
384
+ let solutions = []
385
+ try {
386
+ solutions = await readAll(client, 'solutions', {
387
+ $select: 'uniquename',
388
+ $filter: 'ismanaged eq false',
389
+ $expand: 'publisherid($select=customizationprefix)',
390
+ }, signal)
391
+ } catch {
392
+ solutions = []
393
+ }
394
+
395
+ const counts = new Map()
396
+ for (const row of solutions) {
397
+ const prefix = normalizePrefix(row?.publisherid?.customizationprefix)
398
+ if (prefix) counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
399
+ }
400
+
401
+ if (!counts.size) {
402
+ try {
403
+ const publishers = await readAll(client, 'publishers', {
404
+ $select: 'customizationprefix',
405
+ }, signal)
406
+ for (const row of publishers) {
407
+ const prefix = normalizePrefix(row?.customizationprefix)
408
+ if (prefix) counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
409
+ }
410
+ } catch {
411
+ // leave the list empty; the caller reports that nothing was checked
412
+ }
413
+ }
414
+
415
+ const ranked = [...counts].sort((a, b) => b[1] - a[1])
416
+ const top = ranked[0]?.[1] ?? 0
417
+ const threshold = Math.max(2, Math.ceil(top * PREFIX_SHARE))
418
+ const prefixes = new Set(ranked.filter(([, count]) => count >= threshold).map(([prefix]) => prefix))
419
+ if (prefixes.size > MAX_PREFIXES) {
420
+ prefixes.clear()
421
+ for (const [prefix] of ranked.slice(0, MAX_PREFIXES)) prefixes.add(prefix)
422
+ }
423
+
424
+ // `new` is the prefix the customizer hands out by default; a project that
425
+ // never created a publisher of its own still owns those libraries.
426
+ prefixes.add('new_')
427
+ return [...prefixes].sort()
428
+ }
429
+
430
+ /**
431
+ * Check that every handler a form registers exists in the script it points at.
432
+ * This needs the resource content, so it is opt-in and bounded: only the first
433
+ * `libraryLimit` script libraries referenced by forms are downloaded.
434
+ *
435
+ * The check looks at *every* library the form loads, not just the handler's own
436
+ * one: form event handlers resolve in the form's shared script scope, so
437
+ * `PDS_UI.onSaveProduct` declared on `product.js` may well be provided by
438
+ * `msdyn_productdeterminationservice_ui` that the same form also loads. Looking
439
+ * only at the handler's library produced false positives for exactly that case.
440
+ */
441
+ export async function compareHandlers(clients, downstreams, signal, options = {}) {
442
+ // SIT alone references ~156 distinct project libraries from forms, so a low
443
+ // budget would silently turn later forms into "unreadable" warnings.
444
+ const limit = Number.isFinite(options.libraryLimit) ? options.libraryLimit : 400
445
+ // Only project libraries are worth judging: system forms attach handlers that
446
+ // the platform injects itself (`absoluteurl_onchange`, `Main_Form_onload`),
447
+ // and those never appear in the resource the form lists.
448
+ const prefixes = (options.prefixes ?? []).map((item) => String(item).toLowerCase())
449
+ const isProjectLibrary = (name) => prefixes.some((prefix) => String(name).toLowerCase().startsWith(prefix))
450
+ const findings = []
451
+ for (const downstream of downstreams) {
452
+ // Per environment, not shared: the same missing handler in two environments
453
+ // is two findings, and a shared set reported it only for the first one.
454
+ const seen = new Set()
455
+ const client = clients.get(downstream.env)
456
+ const contents = new Map()
457
+ let fetched = 0
458
+ const load = async (name) => {
459
+ const key = name.toLowerCase()
460
+ if (contents.has(key)) return contents.get(key)
461
+ if (fetched >= limit) return undefined
462
+ fetched += 1
463
+ let content = null
464
+ try {
465
+ content = await readResourceContent(client, name, signal)
466
+ } catch {
467
+ content = null
468
+ }
469
+ contents.set(key, content)
470
+ return content
471
+ }
472
+
473
+ for (const form of downstream.facts.forms) {
474
+ // Judge project handlers against the project libraries the same form
475
+ // loads. Microsoft libraries are skipped on purpose: a handler in a
476
+ // Microsoft library is platform-provided, and pulling every MS script in
477
+ // burns the download budget until later forms read as "unknown".
478
+ const libraries = [...librariesFrom(form.formxml)]
479
+ .filter((name) => name.toLowerCase().endsWith('.js'))
480
+ .filter((name) => isProjectLibrary(name))
481
+ if (!libraries.length) continue
482
+ const handlers = handlersFrom(form.formxml)
483
+ .filter((handler) => handler.libraryName.toLowerCase().endsWith('.js'))
484
+ .filter((handler) => isProjectLibrary(handler.libraryName))
485
+ if (!handlers.length) continue
486
+
487
+ // Resolve each handler against its own library first: that is one download
488
+ // per distinct project library and answers almost every case. Only when the
489
+ // name is missing there do we widen to the other project libraries the form
490
+ // loads — the `PDS_UI` case, where a form-level name lives in a sibling lib.
491
+ let unknown = 0
492
+ const reportable = []
493
+ for (const handler of handlers) {
494
+ const own = await load(handler.libraryName)
495
+ if (typeof own === 'string' && handlerLooksPresent(own.toLowerCase(), handler.functionName)) continue
496
+ reportable.push(handler)
497
+ }
498
+ if (!reportable.length) continue
499
+
500
+ const haystacks = []
501
+ for (const library of libraries) {
502
+ const content = await load(library)
503
+ if (typeof content === 'string') haystacks.push(content.toLowerCase())
504
+ else unknown += 1
505
+ }
506
+ if (!haystacks.length) continue
507
+
508
+ for (const handler of reportable) {
509
+ if (haystacks.some((haystack) => handlerLooksPresent(haystack, handler.functionName))) continue
510
+ const key = `${handler.functionName}|${handler.libraryName}`.toLowerCase()
511
+ if (seen.has(key)) continue
512
+ seen.add(key)
513
+ findings.push(finding('handler-missing', downstream.env, handler.functionName,
514
+ `表单 Handler 指向的函数在它所在的脚本环境里找不到:${handler.functionName}`,
515
+ unknown
516
+ ? `表单「${form.name ?? '未命名'}」加载的 ${libraries.length} 个脚本里有 ${unknown} 个读不到内容(资源不存在或超出上限),无法完全确认;函数也可能由动态加载的脚本提供`
517
+ : `表单「${form.name ?? '未命名'}」加载的 ${libraries.length} 个脚本里都没有这个名字(可能被改名、未部署,或由动态加载的脚本提供)`))
518
+ }
519
+ }
520
+ }
521
+ return findings
522
+ }
523
+
524
+ /**
525
+ * Lenient presence check for a handler. Scripts are often minified or define
526
+ * members through object literals (`LearnMoreDialog.OnLoad = ...`,
527
+ * `OnLoad:function`), so we look for the full name, the last two segments and
528
+ * the bare member name, case-insensitively.
529
+ */
530
+ export function handlerLooksPresent(haystack, functionName) {
531
+ const parts = String(functionName).split('.').filter(Boolean)
532
+ const candidates = [functionName, parts.slice(-2).join('.'), parts[parts.length - 1]]
533
+ return candidates.some((candidate) => candidate && haystack.includes(candidate.toLowerCase()))
534
+ }
535
+
536
+ export async function writeContractReport(config, report) {
537
+ const directory = join(dataRoot(config), 'reports')
538
+ await mkdir(directory, { recursive: true })
539
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
540
+ const jsonPath = join(directory, `contract-${stamp}.json`)
541
+ await writeFile(jsonPath, JSON.stringify(report, null, 2), 'utf8')
542
+ return jsonPath
543
+ }
544
+
545
+ export function renderContractMarkdown(report) {
546
+ const lines = []
547
+ lines.push(`# 契约矩阵 / 漂移:${report.upstream} → ${report.targets.join(', ')}`)
548
+ lines.push('')
549
+ lines.push(`生成时间:${report.generatedAt}`)
550
+ lines.push('')
551
+ if (report.trend) {
552
+ lines.push(`对比上次(${report.trend.comparedTo}):新增 ${report.trend.added.length} 条,已消失 ${report.trend.fixed.length} 条。`)
553
+ lines.push('')
554
+ }
555
+ if (report.waived) {
556
+ lines.push(`已豁免 ${report.waived} 条。`)
557
+ lines.push('')
558
+ }
559
+ lines.push('## 环境规模')
560
+ lines.push('')
561
+ lines.push('| 环境 | 程序集 | 插件类型 | 有步骤的类型 |')
562
+ lines.push('| --- | --- | --- | --- |')
563
+ for (const row of report.rows) {
564
+ lines.push(`| ${row.env} | ${row.assemblies} | ${row.types} | ${row.steps} |`)
565
+ }
566
+ lines.push('')
567
+ const bySeverity = { blocker: [], warning: [] }
568
+ for (const item of report.findings) (bySeverity[item.severity] ?? bySeverity.warning).push(item)
569
+ for (const [severity, title] of [['blocker', '阻断'], ['warning', '警告']]) {
570
+ const items = bySeverity[severity]
571
+ lines.push(`## ${title} ${items.length}`)
572
+ lines.push('')
573
+ if (!items.length) {
574
+ lines.push('无。')
575
+ } else {
576
+ for (const item of items) {
577
+ lines.push(`- [${item.env}] ${item.rule}:${item.message}${item.evidence ? `(${item.evidence})` : ''}`)
578
+ }
579
+ }
580
+ lines.push('')
581
+ }
582
+ return lines.join('\n')
583
+ }