dsh-d365-kit 0.34.1 → 0.34.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- 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
|
+
}
|