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/tool-contract.js
CHANGED
|
@@ -1,376 +1,376 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_contract` — plugin registration as a contract, plus the drift rules
|
|
3
|
-
* that check whether downstream environments still honour it.
|
|
4
|
-
*
|
|
5
|
-
* Actions:
|
|
6
|
-
* drift — compare upstream with downstream, apply waivers, show trend
|
|
7
|
-
* matrix — same read, only the environment overview
|
|
8
|
-
* waive — record an accepted difference (with reason and expiry)
|
|
9
|
-
* waivers — list recorded waivers
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { loadConfig } from './config.js'
|
|
13
|
-
import { D365Error } from './errors.js'
|
|
14
|
-
import {
|
|
15
|
-
RULES,
|
|
16
|
-
compareContracts,
|
|
17
|
-
compareHandlers,
|
|
18
|
-
compareOptionValues,
|
|
19
|
-
compareVisualContract,
|
|
20
|
-
detectHandlerPrefixes,
|
|
21
|
-
indexContract,
|
|
22
|
-
openContractClients,
|
|
23
|
-
readContractFacts,
|
|
24
|
-
renderContractMarkdown,
|
|
25
|
-
writeContractReport,
|
|
26
|
-
} from './contract.js'
|
|
27
|
-
import { addWaiver, applyWaivers, readWaivers, waiversPath } from './waivers.js'
|
|
28
|
-
import { appendHistory, compareWithPrevious, findingKey, readHistory } from './contract-history.js'
|
|
29
|
-
import { enforceRetention } from './retention.js'
|
|
30
|
-
|
|
31
|
-
const ACTIONS = ['drift', 'matrix', 'waive', 'waivers']
|
|
32
|
-
|
|
33
|
-
function pickEnvironments(config, args) {
|
|
34
|
-
const keys = Object.keys(config.environments)
|
|
35
|
-
if (!keys.length) throw new D365Error('还没有配置任何 D365 环境', { hint: '在设置页「环境」里添加环境。' })
|
|
36
|
-
const source = args.source ?? config.defaultEnvironment ?? keys[0]
|
|
37
|
-
if (!config.environments[source]) {
|
|
38
|
-
throw new D365Error(`未知环境:${source}`, { hint: `可用:${keys.join(', ')}` })
|
|
39
|
-
}
|
|
40
|
-
const requested = Array.isArray(args.targets) && args.targets.length ? args.targets : keys.filter((key) => key !== source)
|
|
41
|
-
const targets = [...new Set(requested)].filter((key) => key !== source)
|
|
42
|
-
for (const key of targets) {
|
|
43
|
-
if (!config.environments[key]) throw new D365Error(`未知环境:${key}`, { hint: `可用:${keys.join(', ')}` })
|
|
44
|
-
}
|
|
45
|
-
if (!targets.length) throw new D365Error('没有目标环境可比', { hint: '用 targets 指定下游环境,或至少配置两个环境。' })
|
|
46
|
-
return { source, targets }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function render(value) {
|
|
50
|
-
const lines = []
|
|
51
|
-
if (value.action === 'waivers') {
|
|
52
|
-
lines.push(`当前豁免 ${value.waivers.length} 条(${value.waiversPath})`)
|
|
53
|
-
for (const waiver of value.waivers) {
|
|
54
|
-
lines.push(`- ${waiver.rule} · ${waiver.env} · ${waiver.object}${waiver.expiresAt ? ` · 到期 ${waiver.expiresAt}` : ''} — ${waiver.reason}`)
|
|
55
|
-
}
|
|
56
|
-
return [{ type: 'text', text: lines.join('\n') }]
|
|
57
|
-
}
|
|
58
|
-
if (value.action === 'waive') {
|
|
59
|
-
lines.push(`已记录豁免:${value.waivers.length} 条(${value.waiversPath})`)
|
|
60
|
-
lines.push('下次漂移检查时,匹配的发现会移入「已豁免」而不是阻断。')
|
|
61
|
-
return [{ type: 'text', text: lines.join('\n') }]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
lines.push(value.action === 'matrix' ? '契约矩阵' : '漂移检查')
|
|
65
|
-
lines.push('')
|
|
66
|
-
lines.push(`上游:${value.source} → 下游:${value.targets.join('、')}`)
|
|
67
|
-
lines.push('')
|
|
68
|
-
lines.push('| 环境 | 程序集 | 插件类型 | 有步骤的类型 |')
|
|
69
|
-
lines.push('| --- | --- | --- | --- |')
|
|
70
|
-
for (const row of value.rows) {
|
|
71
|
-
lines.push(`| ${row.env} | ${row.assemblies} | ${row.types} | ${row.steps} |`)
|
|
72
|
-
}
|
|
73
|
-
lines.push('')
|
|
74
|
-
|
|
75
|
-
if (value.handlerPrefixes?.length) {
|
|
76
|
-
const source = value.handlerPrefixSource === 'config' ? '来自配置' : '自动从发布者推断'
|
|
77
|
-
lines.push(`Handler 检查的库前缀:${value.handlerPrefixes.join('、')}(${source})`)
|
|
78
|
-
lines.push('')
|
|
79
|
-
}
|
|
80
|
-
if (value.trend) {
|
|
81
|
-
lines.push(`对比上次(${value.trend.comparedTo}):新增 ${value.trend.added.length} 条,已消失 ${value.trend.fixed.length} 条。`)
|
|
82
|
-
lines.push('')
|
|
83
|
-
}
|
|
84
|
-
if (value.waived) {
|
|
85
|
-
lines.push(`已豁免 ${value.waived} 条(.d365/waivers.json)。`)
|
|
86
|
-
if (value.expired) lines.push(`有 ${value.expired} 条豁免已过期:对应的问题重新计入上面的清单,用 d365_contract action=waivers 查看。`)
|
|
87
|
-
lines.push('')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const blockers = value.findings.filter((item) => item.severity === 'blocker')
|
|
91
|
-
const warnings = value.findings.filter((item) => item.severity !== 'blocker')
|
|
92
|
-
const section = (title, items, limit) => {
|
|
93
|
-
lines.push(`### ${title} ${items.length}`)
|
|
94
|
-
if (!items.length) {
|
|
95
|
-
lines.push('无。')
|
|
96
|
-
} else {
|
|
97
|
-
for (const item of items.slice(0, limit)) {
|
|
98
|
-
lines.push(`- [${item.env}] ${item.message}${item.evidence ? ` — ${item.evidence}` : ''}`)
|
|
99
|
-
}
|
|
100
|
-
if (items.length > limit) lines.push(`- … 其余 ${items.length - limit} 条见报告`)
|
|
101
|
-
}
|
|
102
|
-
lines.push('')
|
|
103
|
-
}
|
|
104
|
-
section('阻断', blockers, 15)
|
|
105
|
-
section('警告', warnings, 15)
|
|
106
|
-
if (value.waived) {
|
|
107
|
-
lines.push(`已豁免 ${value.waived} 条(见 ${value.waiversPath})。`)
|
|
108
|
-
if (value.expired) lines.push(`其中 ${value.expired} 条豁免已过期。`)
|
|
109
|
-
lines.push('')
|
|
110
|
-
}
|
|
111
|
-
lines.push(`报告:${value.reportPath}`)
|
|
112
|
-
return [{ type: 'text', text: lines.join('\n') }]
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function register(ctx, state) {
|
|
116
|
-
const dispose = ctx.tools.register({
|
|
117
|
-
name: 'd365_contract',
|
|
118
|
-
description:
|
|
119
|
-
'契约矩阵与漂移:把上游环境的插件注册(程序集/类型/步骤)当作契约,检查下游缺了什么、版本是不是更旧,可选比对全局选项集的值;支持记录豁免与对比上次运行。只读(waive 会写本机 waivers.json)。',
|
|
120
|
-
parameters: {
|
|
121
|
-
type: 'object',
|
|
122
|
-
additionalProperties: false,
|
|
123
|
-
properties: {
|
|
124
|
-
action: {
|
|
125
|
-
type: 'string',
|
|
126
|
-
enum: ACTIONS,
|
|
127
|
-
description: 'drift:跑规则找漂移(默认);matrix:只看规模;waive:记录豁免;waivers:列豁免。',
|
|
128
|
-
},
|
|
129
|
-
source: { type: 'string', description: '上游环境 key,默认取配置的默认环境。' },
|
|
130
|
-
targets: {
|
|
131
|
-
type: 'array',
|
|
132
|
-
items: { type: 'string' },
|
|
133
|
-
description: '下游环境 key 列表,省略表示除上游外的全部环境。',
|
|
134
|
-
},
|
|
135
|
-
optionSets: {
|
|
136
|
-
type: 'array',
|
|
137
|
-
items: { type: 'string' },
|
|
138
|
-
description: '要逐值比对的全局选项集名称,例如 ["contoso_taxcategoryos"]。',
|
|
139
|
-
},
|
|
140
|
-
includeManaged: {
|
|
141
|
-
type: 'boolean',
|
|
142
|
-
description: '默认只看上游未受管的程序集;设为 true 连受管的一起比(噪声大)。',
|
|
143
|
-
},
|
|
144
|
-
forms: {
|
|
145
|
-
type: 'boolean',
|
|
146
|
-
description: '默认检查表单引用的 Web 资源与站点地图;设为 false 跳过(省掉读 formxml 的开销)。',
|
|
147
|
-
},
|
|
148
|
-
handlers: {
|
|
149
|
-
type: 'boolean',
|
|
150
|
-
description: '设为 true 时额外下载脚本内容,核对表单 Handler 指向的函数是否真的存在(较慢,每个环境最多 400 个脚本)。',
|
|
151
|
-
},
|
|
152
|
-
rule: { type: 'string', enum: RULES.map((rule) => rule.id), description: 'waive:豁免哪条规则。' },
|
|
153
|
-
env: { type: 'string', description: 'waive:豁免哪个环境,* 表示全部。' },
|
|
154
|
-
object: { type: 'string', description: 'waive:豁免哪个对象(类型名/程序集名/选项集名),支持前缀 *。' },
|
|
155
|
-
reason: { type: 'string', description: 'waive:为什么接受这个差异(必填)。' },
|
|
156
|
-
days: { type: 'number', description: 'waive:多少天后过期,默认 30。' },
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
output: {
|
|
160
|
-
schema: {
|
|
161
|
-
type: 'object',
|
|
162
|
-
additionalProperties: false,
|
|
163
|
-
required: ['schemaVersion', 'action'],
|
|
164
|
-
properties: {
|
|
165
|
-
schemaVersion: { type: 'number' },
|
|
166
|
-
action: { type: 'string' },
|
|
167
|
-
source: { type: 'string' },
|
|
168
|
-
targets: { type: 'array', items: { type: 'string' } },
|
|
169
|
-
rows: {
|
|
170
|
-
type: 'array',
|
|
171
|
-
items: {
|
|
172
|
-
type: 'object',
|
|
173
|
-
additionalProperties: false,
|
|
174
|
-
properties: {
|
|
175
|
-
env: { type: 'string' },
|
|
176
|
-
assemblies: { type: 'number' },
|
|
177
|
-
types: { type: 'number' },
|
|
178
|
-
steps: { type: 'number' },
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
findings: {
|
|
183
|
-
type: 'array',
|
|
184
|
-
items: {
|
|
185
|
-
type: 'object',
|
|
186
|
-
additionalProperties: false,
|
|
187
|
-
properties: {
|
|
188
|
-
rule: { type: 'string' },
|
|
189
|
-
severity: { type: 'string' },
|
|
190
|
-
env: { type: 'string' },
|
|
191
|
-
object: { type: 'string' },
|
|
192
|
-
message: { type: 'string' },
|
|
193
|
-
evidence: { type: 'string' },
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
waived: { type: 'number' },
|
|
198
|
-
expired: { type: 'number' },
|
|
199
|
-
handlerPrefixes: { type: 'array', items: { type: 'string' } },
|
|
200
|
-
handlerPrefixSource: { type: 'string' },
|
|
201
|
-
trend: {
|
|
202
|
-
type: 'object',
|
|
203
|
-
additionalProperties: false,
|
|
204
|
-
properties: {
|
|
205
|
-
comparedTo: { type: 'string' },
|
|
206
|
-
added: { type: 'array', items: { type: 'string' } },
|
|
207
|
-
fixed: { type: 'array', items: { type: 'string' } },
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
reportPath: { type: 'string' },
|
|
211
|
-
waivers: {
|
|
212
|
-
type: 'array',
|
|
213
|
-
items: {
|
|
214
|
-
type: 'object',
|
|
215
|
-
additionalProperties: false,
|
|
216
|
-
properties: {
|
|
217
|
-
rule: { type: 'string' },
|
|
218
|
-
env: { type: 'string' },
|
|
219
|
-
object: { type: 'string' },
|
|
220
|
-
reason: { type: 'string' },
|
|
221
|
-
expiresAt: { type: 'string' },
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
waiversPath: { type: 'string' },
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
render: (args, value) => render(value),
|
|
229
|
-
},
|
|
230
|
-
async execute(args, exec) {
|
|
231
|
-
const config = await loadConfig(state.rawConfig)
|
|
232
|
-
const action = ACTIONS.includes(args.action) ? args.action : 'drift'
|
|
233
|
-
|
|
234
|
-
if (action === 'waivers') {
|
|
235
|
-
const document = await readWaivers(config)
|
|
236
|
-
return {
|
|
237
|
-
schemaVersion: 1,
|
|
238
|
-
action: 'waivers',
|
|
239
|
-
waivers: document.waivers.map((waiver) => ({ ...waiver, expiresAt: waiver.expiresAt ?? '' })),
|
|
240
|
-
waiversPath: waiversPath(config),
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (action === 'waive') {
|
|
245
|
-
if (!args.rule) throw new D365Error('waive 需要 rule', { hint: `可用:${RULES.map((rule) => rule.id).join(', ')}` })
|
|
246
|
-
if (!args.env) throw new D365Error('waive 需要 env', { hint: '指定环境 key,或 * 表示全部。' })
|
|
247
|
-
if (!args.object) throw new D365Error('waive 需要 object', { hint: '类型名、程序集名或选项集名;支持前缀 *。' })
|
|
248
|
-
if (!args.reason || !String(args.reason).trim()) {
|
|
249
|
-
throw new D365Error('waive 需要 reason', { hint: '写清楚为什么接受这个差异,半年后有人会看。' })
|
|
250
|
-
}
|
|
251
|
-
const days = Number.isFinite(args.days) && args.days > 0 ? args.days : 30
|
|
252
|
-
const expiresAt = new Date(Date.now() + days * 24 * 3600 * 1000).toISOString()
|
|
253
|
-
const waivers = await addWaiver(config, {
|
|
254
|
-
rule: args.rule,
|
|
255
|
-
env: args.env,
|
|
256
|
-
object: args.object,
|
|
257
|
-
reason: String(args.reason).trim(),
|
|
258
|
-
expiresAt,
|
|
259
|
-
})
|
|
260
|
-
return { schemaVersion: 1, action: 'waive', waivers, waiversPath: waiversPath(config) }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const { source, targets } = pickEnvironments(config, args)
|
|
264
|
-
const clients = await openContractClients(config, [source, ...targets], exec.signal)
|
|
265
|
-
|
|
266
|
-
const facts = new Map()
|
|
267
|
-
for (const key of [source, ...targets]) {
|
|
268
|
-
facts.set(key, await readContractFacts(clients.get(key), exec.signal, {
|
|
269
|
-
includeVisual: args.forms !== false,
|
|
270
|
-
}))
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const upstream = { env: source, facts: facts.get(source) }
|
|
274
|
-
const downstreams = targets.map((key) => ({ env: key, facts: facts.get(key) }))
|
|
275
|
-
const { findings, rows: downstreamRows } = compareContracts(upstream, downstreams, {
|
|
276
|
-
includeManaged: args.includeManaged === true,
|
|
277
|
-
})
|
|
278
|
-
const visualFindings = args.forms === false ? [] : compareVisualContract(upstream, downstreams)
|
|
279
|
-
|
|
280
|
-
// Handler judging needs to know which script libraries are project code.
|
|
281
|
-
// The config can pin the list; otherwise infer it from the publishers of
|
|
282
|
-
// the source environment, so the package works against any organization.
|
|
283
|
-
let handlerPrefixes = []
|
|
284
|
-
let handlerPrefixSource = ''
|
|
285
|
-
let handlerFindings = []
|
|
286
|
-
if (args.handlers === true && args.forms !== false) {
|
|
287
|
-
const configured = Array.isArray(config.handlerPrefixes) ? config.handlerPrefixes : []
|
|
288
|
-
handlerPrefixes = configured
|
|
289
|
-
handlerPrefixSource = configured.length ? 'config' : 'publishers'
|
|
290
|
-
if (!configured.length) {
|
|
291
|
-
handlerPrefixes = await detectHandlerPrefixes(clients.get(source), exec.signal)
|
|
292
|
-
}
|
|
293
|
-
handlerFindings = await compareHandlers(clients, downstreams, exec.signal, {
|
|
294
|
-
prefixes: handlerPrefixes,
|
|
295
|
-
})
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
let allFindings = [...findings, ...visualFindings, ...handlerFindings]
|
|
299
|
-
if (Array.isArray(args.optionSets) && args.optionSets.length) {
|
|
300
|
-
const optionFindings = await compareOptionValues(
|
|
301
|
-
clients.get(source),
|
|
302
|
-
targets.map((key) => ({ env: key, client: clients.get(key) })),
|
|
303
|
-
args.optionSets,
|
|
304
|
-
exec.signal,
|
|
305
|
-
)
|
|
306
|
-
allFindings = [...findings, ...visualFindings, ...handlerFindings, ...optionFindings]
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const waiverDocument = await readWaivers(config)
|
|
310
|
-
const { kept, waived, expired } = applyWaivers(allFindings, waiverDocument)
|
|
311
|
-
|
|
312
|
-
const upstreamIndex = indexContract(upstream.facts)
|
|
313
|
-
const rows = [
|
|
314
|
-
{
|
|
315
|
-
env: source,
|
|
316
|
-
assemblies: upstreamIndex.assemblies.size,
|
|
317
|
-
types: upstreamIndex.types.size,
|
|
318
|
-
steps: upstream.facts.stepsByType.size,
|
|
319
|
-
},
|
|
320
|
-
...downstreamRows,
|
|
321
|
-
]
|
|
322
|
-
|
|
323
|
-
const history = await readHistory(config)
|
|
324
|
-
const previous = [...history.runs].reverse().find(
|
|
325
|
-
(run) => run.source === source && JSON.stringify(run.targets) === JSON.stringify(targets),
|
|
326
|
-
)
|
|
327
|
-
const currentKeys = kept.map(findingKey)
|
|
328
|
-
const trend = compareWithPrevious(previous, currentKeys)
|
|
329
|
-
|
|
330
|
-
const report = {
|
|
331
|
-
schemaVersion: 1,
|
|
332
|
-
generatedAt: new Date().toISOString(),
|
|
333
|
-
source,
|
|
334
|
-
targets,
|
|
335
|
-
rows,
|
|
336
|
-
findings: kept,
|
|
337
|
-
waived: waived.length,
|
|
338
|
-
expired: expired.length,
|
|
339
|
-
trend,
|
|
340
|
-
...(handlerPrefixes.length ? { handlerPrefixes, handlerPrefixSource } : {}),
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const reportPath = await writeContractReport(config, report)
|
|
344
|
-
const markdown = renderContractMarkdown({ ...report, waived: waived.length })
|
|
345
|
-
const { writeFile } = await import('node:fs/promises')
|
|
346
|
-
await writeFile(reportPath.replace(/\.json$/, '.md'), markdown, 'utf8')
|
|
347
|
-
await enforceRetention(config, { target: 'reports' })
|
|
348
|
-
|
|
349
|
-
await appendHistory(config, {
|
|
350
|
-
at: report.generatedAt,
|
|
351
|
-
source,
|
|
352
|
-
targets,
|
|
353
|
-
blocker: kept.filter((item) => item.severity === 'blocker').length,
|
|
354
|
-
warning: kept.filter((item) => item.severity !== 'blocker').length,
|
|
355
|
-
waived: waived.length,
|
|
356
|
-
keys: currentKeys,
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
schemaVersion: 1,
|
|
361
|
-
action,
|
|
362
|
-
source,
|
|
363
|
-
targets,
|
|
364
|
-
rows,
|
|
365
|
-
findings: kept,
|
|
366
|
-
waived: waived.length,
|
|
367
|
-
expired: expired.length,
|
|
368
|
-
...(trend ? { trend } : {}),
|
|
369
|
-
...(handlerPrefixes.length ? { handlerPrefixes, handlerPrefixSource } : {}),
|
|
370
|
-
reportPath,
|
|
371
|
-
waiversPath: waiversPath(config),
|
|
372
|
-
}
|
|
373
|
-
},
|
|
374
|
-
})
|
|
375
|
-
return dispose
|
|
376
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_contract` — plugin registration as a contract, plus the drift rules
|
|
3
|
+
* that check whether downstream environments still honour it.
|
|
4
|
+
*
|
|
5
|
+
* Actions:
|
|
6
|
+
* drift — compare upstream with downstream, apply waivers, show trend
|
|
7
|
+
* matrix — same read, only the environment overview
|
|
8
|
+
* waive — record an accepted difference (with reason and expiry)
|
|
9
|
+
* waivers — list recorded waivers
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadConfig } from './config.js'
|
|
13
|
+
import { D365Error } from './errors.js'
|
|
14
|
+
import {
|
|
15
|
+
RULES,
|
|
16
|
+
compareContracts,
|
|
17
|
+
compareHandlers,
|
|
18
|
+
compareOptionValues,
|
|
19
|
+
compareVisualContract,
|
|
20
|
+
detectHandlerPrefixes,
|
|
21
|
+
indexContract,
|
|
22
|
+
openContractClients,
|
|
23
|
+
readContractFacts,
|
|
24
|
+
renderContractMarkdown,
|
|
25
|
+
writeContractReport,
|
|
26
|
+
} from './contract.js'
|
|
27
|
+
import { addWaiver, applyWaivers, readWaivers, waiversPath } from './waivers.js'
|
|
28
|
+
import { appendHistory, compareWithPrevious, findingKey, readHistory } from './contract-history.js'
|
|
29
|
+
import { enforceRetention } from './retention.js'
|
|
30
|
+
|
|
31
|
+
const ACTIONS = ['drift', 'matrix', 'waive', 'waivers']
|
|
32
|
+
|
|
33
|
+
function pickEnvironments(config, args) {
|
|
34
|
+
const keys = Object.keys(config.environments)
|
|
35
|
+
if (!keys.length) throw new D365Error('还没有配置任何 D365 环境', { hint: '在设置页「环境」里添加环境。' })
|
|
36
|
+
const source = args.source ?? config.defaultEnvironment ?? keys[0]
|
|
37
|
+
if (!config.environments[source]) {
|
|
38
|
+
throw new D365Error(`未知环境:${source}`, { hint: `可用:${keys.join(', ')}` })
|
|
39
|
+
}
|
|
40
|
+
const requested = Array.isArray(args.targets) && args.targets.length ? args.targets : keys.filter((key) => key !== source)
|
|
41
|
+
const targets = [...new Set(requested)].filter((key) => key !== source)
|
|
42
|
+
for (const key of targets) {
|
|
43
|
+
if (!config.environments[key]) throw new D365Error(`未知环境:${key}`, { hint: `可用:${keys.join(', ')}` })
|
|
44
|
+
}
|
|
45
|
+
if (!targets.length) throw new D365Error('没有目标环境可比', { hint: '用 targets 指定下游环境,或至少配置两个环境。' })
|
|
46
|
+
return { source, targets }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function render(value) {
|
|
50
|
+
const lines = []
|
|
51
|
+
if (value.action === 'waivers') {
|
|
52
|
+
lines.push(`当前豁免 ${value.waivers.length} 条(${value.waiversPath})`)
|
|
53
|
+
for (const waiver of value.waivers) {
|
|
54
|
+
lines.push(`- ${waiver.rule} · ${waiver.env} · ${waiver.object}${waiver.expiresAt ? ` · 到期 ${waiver.expiresAt}` : ''} — ${waiver.reason}`)
|
|
55
|
+
}
|
|
56
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
57
|
+
}
|
|
58
|
+
if (value.action === 'waive') {
|
|
59
|
+
lines.push(`已记录豁免:${value.waivers.length} 条(${value.waiversPath})`)
|
|
60
|
+
lines.push('下次漂移检查时,匹配的发现会移入「已豁免」而不是阻断。')
|
|
61
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push(value.action === 'matrix' ? '契约矩阵' : '漂移检查')
|
|
65
|
+
lines.push('')
|
|
66
|
+
lines.push(`上游:${value.source} → 下游:${value.targets.join('、')}`)
|
|
67
|
+
lines.push('')
|
|
68
|
+
lines.push('| 环境 | 程序集 | 插件类型 | 有步骤的类型 |')
|
|
69
|
+
lines.push('| --- | --- | --- | --- |')
|
|
70
|
+
for (const row of value.rows) {
|
|
71
|
+
lines.push(`| ${row.env} | ${row.assemblies} | ${row.types} | ${row.steps} |`)
|
|
72
|
+
}
|
|
73
|
+
lines.push('')
|
|
74
|
+
|
|
75
|
+
if (value.handlerPrefixes?.length) {
|
|
76
|
+
const source = value.handlerPrefixSource === 'config' ? '来自配置' : '自动从发布者推断'
|
|
77
|
+
lines.push(`Handler 检查的库前缀:${value.handlerPrefixes.join('、')}(${source})`)
|
|
78
|
+
lines.push('')
|
|
79
|
+
}
|
|
80
|
+
if (value.trend) {
|
|
81
|
+
lines.push(`对比上次(${value.trend.comparedTo}):新增 ${value.trend.added.length} 条,已消失 ${value.trend.fixed.length} 条。`)
|
|
82
|
+
lines.push('')
|
|
83
|
+
}
|
|
84
|
+
if (value.waived) {
|
|
85
|
+
lines.push(`已豁免 ${value.waived} 条(.d365/waivers.json)。`)
|
|
86
|
+
if (value.expired) lines.push(`有 ${value.expired} 条豁免已过期:对应的问题重新计入上面的清单,用 d365_contract action=waivers 查看。`)
|
|
87
|
+
lines.push('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const blockers = value.findings.filter((item) => item.severity === 'blocker')
|
|
91
|
+
const warnings = value.findings.filter((item) => item.severity !== 'blocker')
|
|
92
|
+
const section = (title, items, limit) => {
|
|
93
|
+
lines.push(`### ${title} ${items.length}`)
|
|
94
|
+
if (!items.length) {
|
|
95
|
+
lines.push('无。')
|
|
96
|
+
} else {
|
|
97
|
+
for (const item of items.slice(0, limit)) {
|
|
98
|
+
lines.push(`- [${item.env}] ${item.message}${item.evidence ? ` — ${item.evidence}` : ''}`)
|
|
99
|
+
}
|
|
100
|
+
if (items.length > limit) lines.push(`- … 其余 ${items.length - limit} 条见报告`)
|
|
101
|
+
}
|
|
102
|
+
lines.push('')
|
|
103
|
+
}
|
|
104
|
+
section('阻断', blockers, 15)
|
|
105
|
+
section('警告', warnings, 15)
|
|
106
|
+
if (value.waived) {
|
|
107
|
+
lines.push(`已豁免 ${value.waived} 条(见 ${value.waiversPath})。`)
|
|
108
|
+
if (value.expired) lines.push(`其中 ${value.expired} 条豁免已过期。`)
|
|
109
|
+
lines.push('')
|
|
110
|
+
}
|
|
111
|
+
lines.push(`报告:${value.reportPath}`)
|
|
112
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function register(ctx, state) {
|
|
116
|
+
const dispose = ctx.tools.register({
|
|
117
|
+
name: 'd365_contract',
|
|
118
|
+
description:
|
|
119
|
+
'契约矩阵与漂移:把上游环境的插件注册(程序集/类型/步骤)当作契约,检查下游缺了什么、版本是不是更旧,可选比对全局选项集的值;支持记录豁免与对比上次运行。只读(waive 会写本机 waivers.json)。',
|
|
120
|
+
parameters: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
properties: {
|
|
124
|
+
action: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
enum: ACTIONS,
|
|
127
|
+
description: 'drift:跑规则找漂移(默认);matrix:只看规模;waive:记录豁免;waivers:列豁免。',
|
|
128
|
+
},
|
|
129
|
+
source: { type: 'string', description: '上游环境 key,默认取配置的默认环境。' },
|
|
130
|
+
targets: {
|
|
131
|
+
type: 'array',
|
|
132
|
+
items: { type: 'string' },
|
|
133
|
+
description: '下游环境 key 列表,省略表示除上游外的全部环境。',
|
|
134
|
+
},
|
|
135
|
+
optionSets: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
items: { type: 'string' },
|
|
138
|
+
description: '要逐值比对的全局选项集名称,例如 ["contoso_taxcategoryos"]。',
|
|
139
|
+
},
|
|
140
|
+
includeManaged: {
|
|
141
|
+
type: 'boolean',
|
|
142
|
+
description: '默认只看上游未受管的程序集;设为 true 连受管的一起比(噪声大)。',
|
|
143
|
+
},
|
|
144
|
+
forms: {
|
|
145
|
+
type: 'boolean',
|
|
146
|
+
description: '默认检查表单引用的 Web 资源与站点地图;设为 false 跳过(省掉读 formxml 的开销)。',
|
|
147
|
+
},
|
|
148
|
+
handlers: {
|
|
149
|
+
type: 'boolean',
|
|
150
|
+
description: '设为 true 时额外下载脚本内容,核对表单 Handler 指向的函数是否真的存在(较慢,每个环境最多 400 个脚本)。',
|
|
151
|
+
},
|
|
152
|
+
rule: { type: 'string', enum: RULES.map((rule) => rule.id), description: 'waive:豁免哪条规则。' },
|
|
153
|
+
env: { type: 'string', description: 'waive:豁免哪个环境,* 表示全部。' },
|
|
154
|
+
object: { type: 'string', description: 'waive:豁免哪个对象(类型名/程序集名/选项集名),支持前缀 *。' },
|
|
155
|
+
reason: { type: 'string', description: 'waive:为什么接受这个差异(必填)。' },
|
|
156
|
+
days: { type: 'number', description: 'waive:多少天后过期,默认 30。' },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
output: {
|
|
160
|
+
schema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
required: ['schemaVersion', 'action'],
|
|
164
|
+
properties: {
|
|
165
|
+
schemaVersion: { type: 'number' },
|
|
166
|
+
action: { type: 'string' },
|
|
167
|
+
source: { type: 'string' },
|
|
168
|
+
targets: { type: 'array', items: { type: 'string' } },
|
|
169
|
+
rows: {
|
|
170
|
+
type: 'array',
|
|
171
|
+
items: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
properties: {
|
|
175
|
+
env: { type: 'string' },
|
|
176
|
+
assemblies: { type: 'number' },
|
|
177
|
+
types: { type: 'number' },
|
|
178
|
+
steps: { type: 'number' },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
findings: {
|
|
183
|
+
type: 'array',
|
|
184
|
+
items: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
properties: {
|
|
188
|
+
rule: { type: 'string' },
|
|
189
|
+
severity: { type: 'string' },
|
|
190
|
+
env: { type: 'string' },
|
|
191
|
+
object: { type: 'string' },
|
|
192
|
+
message: { type: 'string' },
|
|
193
|
+
evidence: { type: 'string' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
waived: { type: 'number' },
|
|
198
|
+
expired: { type: 'number' },
|
|
199
|
+
handlerPrefixes: { type: 'array', items: { type: 'string' } },
|
|
200
|
+
handlerPrefixSource: { type: 'string' },
|
|
201
|
+
trend: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
properties: {
|
|
205
|
+
comparedTo: { type: 'string' },
|
|
206
|
+
added: { type: 'array', items: { type: 'string' } },
|
|
207
|
+
fixed: { type: 'array', items: { type: 'string' } },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
reportPath: { type: 'string' },
|
|
211
|
+
waivers: {
|
|
212
|
+
type: 'array',
|
|
213
|
+
items: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
properties: {
|
|
217
|
+
rule: { type: 'string' },
|
|
218
|
+
env: { type: 'string' },
|
|
219
|
+
object: { type: 'string' },
|
|
220
|
+
reason: { type: 'string' },
|
|
221
|
+
expiresAt: { type: 'string' },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
waiversPath: { type: 'string' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
render: (args, value) => render(value),
|
|
229
|
+
},
|
|
230
|
+
async execute(args, exec) {
|
|
231
|
+
const config = await loadConfig(state.rawConfig)
|
|
232
|
+
const action = ACTIONS.includes(args.action) ? args.action : 'drift'
|
|
233
|
+
|
|
234
|
+
if (action === 'waivers') {
|
|
235
|
+
const document = await readWaivers(config)
|
|
236
|
+
return {
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
action: 'waivers',
|
|
239
|
+
waivers: document.waivers.map((waiver) => ({ ...waiver, expiresAt: waiver.expiresAt ?? '' })),
|
|
240
|
+
waiversPath: waiversPath(config),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (action === 'waive') {
|
|
245
|
+
if (!args.rule) throw new D365Error('waive 需要 rule', { hint: `可用:${RULES.map((rule) => rule.id).join(', ')}` })
|
|
246
|
+
if (!args.env) throw new D365Error('waive 需要 env', { hint: '指定环境 key,或 * 表示全部。' })
|
|
247
|
+
if (!args.object) throw new D365Error('waive 需要 object', { hint: '类型名、程序集名或选项集名;支持前缀 *。' })
|
|
248
|
+
if (!args.reason || !String(args.reason).trim()) {
|
|
249
|
+
throw new D365Error('waive 需要 reason', { hint: '写清楚为什么接受这个差异,半年后有人会看。' })
|
|
250
|
+
}
|
|
251
|
+
const days = Number.isFinite(args.days) && args.days > 0 ? args.days : 30
|
|
252
|
+
const expiresAt = new Date(Date.now() + days * 24 * 3600 * 1000).toISOString()
|
|
253
|
+
const waivers = await addWaiver(config, {
|
|
254
|
+
rule: args.rule,
|
|
255
|
+
env: args.env,
|
|
256
|
+
object: args.object,
|
|
257
|
+
reason: String(args.reason).trim(),
|
|
258
|
+
expiresAt,
|
|
259
|
+
})
|
|
260
|
+
return { schemaVersion: 1, action: 'waive', waivers, waiversPath: waiversPath(config) }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const { source, targets } = pickEnvironments(config, args)
|
|
264
|
+
const clients = await openContractClients(config, [source, ...targets], exec.signal)
|
|
265
|
+
|
|
266
|
+
const facts = new Map()
|
|
267
|
+
for (const key of [source, ...targets]) {
|
|
268
|
+
facts.set(key, await readContractFacts(clients.get(key), exec.signal, {
|
|
269
|
+
includeVisual: args.forms !== false,
|
|
270
|
+
}))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const upstream = { env: source, facts: facts.get(source) }
|
|
274
|
+
const downstreams = targets.map((key) => ({ env: key, facts: facts.get(key) }))
|
|
275
|
+
const { findings, rows: downstreamRows } = compareContracts(upstream, downstreams, {
|
|
276
|
+
includeManaged: args.includeManaged === true,
|
|
277
|
+
})
|
|
278
|
+
const visualFindings = args.forms === false ? [] : compareVisualContract(upstream, downstreams)
|
|
279
|
+
|
|
280
|
+
// Handler judging needs to know which script libraries are project code.
|
|
281
|
+
// The config can pin the list; otherwise infer it from the publishers of
|
|
282
|
+
// the source environment, so the package works against any organization.
|
|
283
|
+
let handlerPrefixes = []
|
|
284
|
+
let handlerPrefixSource = ''
|
|
285
|
+
let handlerFindings = []
|
|
286
|
+
if (args.handlers === true && args.forms !== false) {
|
|
287
|
+
const configured = Array.isArray(config.handlerPrefixes) ? config.handlerPrefixes : []
|
|
288
|
+
handlerPrefixes = configured
|
|
289
|
+
handlerPrefixSource = configured.length ? 'config' : 'publishers'
|
|
290
|
+
if (!configured.length) {
|
|
291
|
+
handlerPrefixes = await detectHandlerPrefixes(clients.get(source), exec.signal)
|
|
292
|
+
}
|
|
293
|
+
handlerFindings = await compareHandlers(clients, downstreams, exec.signal, {
|
|
294
|
+
prefixes: handlerPrefixes,
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let allFindings = [...findings, ...visualFindings, ...handlerFindings]
|
|
299
|
+
if (Array.isArray(args.optionSets) && args.optionSets.length) {
|
|
300
|
+
const optionFindings = await compareOptionValues(
|
|
301
|
+
clients.get(source),
|
|
302
|
+
targets.map((key) => ({ env: key, client: clients.get(key) })),
|
|
303
|
+
args.optionSets,
|
|
304
|
+
exec.signal,
|
|
305
|
+
)
|
|
306
|
+
allFindings = [...findings, ...visualFindings, ...handlerFindings, ...optionFindings]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const waiverDocument = await readWaivers(config)
|
|
310
|
+
const { kept, waived, expired } = applyWaivers(allFindings, waiverDocument)
|
|
311
|
+
|
|
312
|
+
const upstreamIndex = indexContract(upstream.facts)
|
|
313
|
+
const rows = [
|
|
314
|
+
{
|
|
315
|
+
env: source,
|
|
316
|
+
assemblies: upstreamIndex.assemblies.size,
|
|
317
|
+
types: upstreamIndex.types.size,
|
|
318
|
+
steps: upstream.facts.stepsByType.size,
|
|
319
|
+
},
|
|
320
|
+
...downstreamRows,
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
const history = await readHistory(config)
|
|
324
|
+
const previous = [...history.runs].reverse().find(
|
|
325
|
+
(run) => run.source === source && JSON.stringify(run.targets) === JSON.stringify(targets),
|
|
326
|
+
)
|
|
327
|
+
const currentKeys = kept.map(findingKey)
|
|
328
|
+
const trend = compareWithPrevious(previous, currentKeys)
|
|
329
|
+
|
|
330
|
+
const report = {
|
|
331
|
+
schemaVersion: 1,
|
|
332
|
+
generatedAt: new Date().toISOString(),
|
|
333
|
+
source,
|
|
334
|
+
targets,
|
|
335
|
+
rows,
|
|
336
|
+
findings: kept,
|
|
337
|
+
waived: waived.length,
|
|
338
|
+
expired: expired.length,
|
|
339
|
+
trend,
|
|
340
|
+
...(handlerPrefixes.length ? { handlerPrefixes, handlerPrefixSource } : {}),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const reportPath = await writeContractReport(config, report)
|
|
344
|
+
const markdown = renderContractMarkdown({ ...report, waived: waived.length })
|
|
345
|
+
const { writeFile } = await import('node:fs/promises')
|
|
346
|
+
await writeFile(reportPath.replace(/\.json$/, '.md'), markdown, 'utf8')
|
|
347
|
+
await enforceRetention(config, { target: 'reports' })
|
|
348
|
+
|
|
349
|
+
await appendHistory(config, {
|
|
350
|
+
at: report.generatedAt,
|
|
351
|
+
source,
|
|
352
|
+
targets,
|
|
353
|
+
blocker: kept.filter((item) => item.severity === 'blocker').length,
|
|
354
|
+
warning: kept.filter((item) => item.severity !== 'blocker').length,
|
|
355
|
+
waived: waived.length,
|
|
356
|
+
keys: currentKeys,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
schemaVersion: 1,
|
|
361
|
+
action,
|
|
362
|
+
source,
|
|
363
|
+
targets,
|
|
364
|
+
rows,
|
|
365
|
+
findings: kept,
|
|
366
|
+
waived: waived.length,
|
|
367
|
+
expired: expired.length,
|
|
368
|
+
...(trend ? { trend } : {}),
|
|
369
|
+
...(handlerPrefixes.length ? { handlerPrefixes, handlerPrefixSource } : {}),
|
|
370
|
+
reportPath,
|
|
371
|
+
waiversPath: waiversPath(config),
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
return dispose
|
|
376
|
+
}
|