@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,661 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2 评分子插件 — 静态 A–E 评分 + 环境阈值(契约:docs/CONTRACTS-V2.md 5.4)。
4
+ //
5
+ // 评分数学与 v1 完全一致(移植自 `dsh-hot-update/packages/score-guard/index.js`):
6
+ // * D1–D6 维度权重(DIM_WEIGHT)
7
+ // * 信号族 → 维度映射(SIGNAL_DIM),同族信号按 family 去重(dedupeByFamily)
8
+ // * C3 服务宽限:<9 个服务且不含 sessions/registry/loader 时权重归零
9
+ // * 分档 A/B/C/D/E(分档上界由 thresholds 决定;**母体默认 = v1 的 20/40/60/80**)
10
+ // * degraded(入口扫描有 tokenizer warning)时 A、B 一律降 C
11
+ // * `pkg.dsh.hotReload === false` → E veto(S=100,直接否决)
12
+ //
13
+ // v2 增量:
14
+ // * apply 首行 roleGate('scorer'):scorer 在 mother 与 fork 都激活。
15
+ // * Config.thresholds{mother,fork}:母体 20/40/60/80(= v1),分身更宽 25/45/65/85。
16
+ // * scoreSource/scoreDir/scoreIntent 支持 opts.environment(缺省 = 本实例部署角色 resolveRole),
17
+ // 每个返回值都带 basis{environment, thresholds:{a,b,c,d}, note:'本评分基于 <environment> 阈值'}。
18
+ // * 同一份源码在母体/分身阈值下可以落到不同 grade(S 落在两条分档上界之间时),分叉真实存在。
19
+ //
20
+ // import 层扫描器在 `./imports.mjs`(零依赖手写 tokenizer)。本插件只做 import 层近似:
21
+ // 不做调用层(exec/spawn 的实际调用点)分析,这一局限在 coverage='import-only' 与 t6-report.md 中如实声明。
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { SERVICE, AUDIT, ROLE, z, readJson, roleGate } from '../kernel/index.mjs'
25
+ import { scanSource, scanDeep, importsToSignals } from './imports.mjs'
26
+
27
+ export const name = 'fang-scorer'
28
+ export const inject = []
29
+
30
+ /** 分档上界:A ≤ a,B ≤ b,C ≤ c,D ≤ d,E > d。母体 = v1 原样;分身更宽。 */
31
+ export const BAND_DEFAULTS = {
32
+ mother: { a: 20, b: 40, c: 60, d: 80 },
33
+ fork: { a: 25, b: 45, c: 65, d: 85 },
34
+ }
35
+
36
+ /** v1 分值/分档的别名(scoreScan 缺省仍按母体阈值)。 */
37
+ export const DEFAULT_THRESHOLDS = BAND_DEFAULTS
38
+
39
+ const bandSchema = (defaults) => z.object({
40
+ a: z.number().default(defaults.a),
41
+ b: z.number().default(defaults.b),
42
+ c: z.number().default(defaults.c),
43
+ d: z.number().default(defaults.d),
44
+ }).default({})
45
+
46
+ export const Config = z.object({
47
+ allowlist: z.array(z.string()).default([])
48
+ .description('额外允许的依赖名(v1.0.0 只记录在 policy,评分本身不消费;healer 的 G5 会读同一配置)'),
49
+ thresholds: z.object({
50
+ mother: bandSchema(BAND_DEFAULTS.mother)
51
+ .description('母体分档上界(默认 = v1:20/40/60/80)'),
52
+ fork: bandSchema(BAND_DEFAULTS.fork)
53
+ .description('分身分档上界(默认更宽:25/45/65/85)'),
54
+ }).default({}),
55
+ })
56
+
57
+ /** 维度权重(与参考实现完全一致)。 */
58
+ export const DIM_WEIGHT = { D1: 28, D2: 22, D3: 18, D4: 14, D5: 10, D6: 8 }
59
+
60
+ /** 信号族 → 维度(与参考实现完全一致)。 */
61
+ export const SIGNAL_DIM = {
62
+ S1: 'D1', S2: 'D1', S3: 'D1', S4: 'D1', S5: 'D1', S7: 'D1',
63
+ S6: 'D5', C2: 'D4', C3: 'D2', C4: 'D2', C5: 'D4',
64
+ }
65
+
66
+ export const ENVIRONMENTS = [ROLE.MOTHER, ROLE.FORK]
67
+
68
+ /** 信号 id 的族名:'S1-child_process' → 'S1'。 */
69
+ export function familyOf(id) {
70
+ return String(id ?? '').split('-')[0]
71
+ }
72
+
73
+ /** 同族只保留第一个信号(族内取最大风险由维度聚合负责)。 */
74
+ export function dedupeByFamily(signals) {
75
+ const seen = new Set()
76
+ const out = []
77
+ for (const s of signals ?? []) {
78
+ const f = familyOf(s?.id)
79
+ if (seen.has(f)) continue
80
+ seen.add(f)
81
+ out.push(s)
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** 环境名归一化:只接受 mother/fork,其它回退。 */
87
+ export function normalizeEnvironment(value, fallback = ROLE.MOTHER) {
88
+ const v = String(value ?? '').toLowerCase()
89
+ return v === ROLE.MOTHER || v === ROLE.FORK ? v : fallback
90
+ }
91
+
92
+ /** opts(对象 | 字符串 | 空)→ 评分环境。 */
93
+ export function environmentOf(opts, fallback = ROLE.MOTHER) {
94
+ if (typeof opts === 'string') return normalizeEnvironment(opts, fallback)
95
+ if (opts && typeof opts === 'object') return normalizeEnvironment(opts.environment, fallback)
96
+ return normalizeEnvironment(undefined, fallback)
97
+ }
98
+
99
+ /** 单个分档表归一化(缺字段回退到 fallback)。 */
100
+ export function normalizeBands(input, fallback = BAND_DEFAULTS.mother) {
101
+ const base = fallback && typeof fallback === 'object' ? fallback : BAND_DEFAULTS.mother
102
+ const src = input && typeof input === 'object' ? input : {}
103
+ const num = (k) => (typeof src[k] === 'number' && Number.isFinite(src[k]) ? src[k] : base[k])
104
+ return { a: num('a'), b: num('b'), c: num('c'), d: num('d') }
105
+ }
106
+
107
+ /** 完整 {mother,fork} 阈值配置归一化。 */
108
+ export function normalizeThresholds(input) {
109
+ const src = input && typeof input === 'object' ? input : {}
110
+ return {
111
+ mother: normalizeBands(src.mother, BAND_DEFAULTS.mother),
112
+ fork: normalizeBands(src.fork, BAND_DEFAULTS.fork),
113
+ }
114
+ }
115
+
116
+ export function defaultBands(environment = ROLE.MOTHER) {
117
+ return environment === ROLE.FORK ? { ...BAND_DEFAULTS.fork } : { ...BAND_DEFAULTS.mother }
118
+ }
119
+
120
+ /**
121
+ * 取某环境的分档表。thresholds 既可以是完整的 {mother,fork},也可以是单张 {a,b,c,d}。
122
+ */
123
+ export function resolveBands(environment, thresholds = null) {
124
+ const env = normalizeEnvironment(environment)
125
+ const t = thresholds && typeof thresholds === 'object' ? thresholds : {}
126
+ const flat = ['a', 'b', 'c', 'd'].some(k => typeof t[k] === 'number')
127
+ if (flat) return normalizeBands(t, defaultBands(env))
128
+ return normalizeBands(env === ROLE.FORK ? t.fork : t.mother, defaultBands(env))
129
+ }
130
+
131
+ /** 分值 → 等级(分档上界来自 thresholds)。 */
132
+ export function gradeOf(S, bands = BAND_DEFAULTS.mother) {
133
+ const b = normalizeBands(bands, BAND_DEFAULTS.mother)
134
+ const s = Number.isFinite(Number(S)) ? Number(S) : 0
135
+ return s <= b.a ? 'A' : s <= b.b ? 'B' : s <= b.c ? 'C' : s <= b.d ? 'D' : 'E'
136
+ }
137
+
138
+ /** 评分依据注解(契约 5.4)。 */
139
+ export function basisOf(environment, bands = null) {
140
+ const env = normalizeEnvironment(environment)
141
+ const thresholds = bands === null ? defaultBands(env) : resolveBands(env, bands)
142
+ return { environment: env, thresholds, note: `本评分基于 ${env} 阈值` }
143
+ }
144
+
145
+ /**
146
+ * 纯评分:信号 → {grade, S, veto, degraded, perDim, reasons}。
147
+ * 与参考实现 scoreScan 同构;第三参数是分档表(缺省 = 母体 v1 分档,所以 v1 数值行为不变)。
148
+ */
149
+ export function scoreScan(signals, pkg = {}, bands = BAND_DEFAULTS.mother) {
150
+ const thresholds = resolveBands(ROLE.MOTHER, bands)
151
+ const c3 = { count: 0, core: false, graced: false, applied: false }
152
+ if (pkg?.dsh?.hotReload === false) {
153
+ return { grade: 'E', S: 100, veto: true, degraded: false, perDim: {}, reasons: ['hotReload-false'], c3 }
154
+ }
155
+
156
+ const reasons = []
157
+ const uniq = dedupeByFamily(signals)
158
+ const perDim = {}
159
+
160
+ for (const s of uniq) {
161
+ let weight = Number(s?.weight ?? 0)
162
+ const fam = familyOf(s?.id)
163
+
164
+ // C3 服务宽限:少数非核心服务不算危险面,权重归零。
165
+ if (fam === 'C3') {
166
+ const services = Array.isArray(s?.evidence?.services) ? s.evidence.services : []
167
+ const core = services.some(x => ['sessions', 'registry', 'loader'].includes(x))
168
+ const graced = !(services.length > 8 || core)
169
+ c3.count = Math.max(c3.count, services.length)
170
+ c3.core = c3.core || core
171
+ c3.graced = graced
172
+ c3.applied = true
173
+ weight = graced ? 0 : weight
174
+ if (weight <= 0) {
175
+ reasons.push(`${s?.id}:C3-service-grace`)
176
+ continue
177
+ }
178
+ reasons.push(`${s?.id}:C3-service-risk(count=${services.length},core=${core})`)
179
+ }
180
+ if (!(weight > 0)) continue
181
+
182
+ const dim = SIGNAL_DIM[fam] ?? 'D1'
183
+ const sev = Math.min(5, Math.round((weight / 30) * 5))
184
+ if (sev <= 0) continue
185
+ perDim[dim] = Math.max(perDim[dim] ?? 0, sev)
186
+ reasons.push(`${s.id}→${dim}(w=${weight},sev=${sev})`)
187
+ }
188
+
189
+ let S = 0
190
+ for (const [dim, sev] of Object.entries(perDim)) S += ((DIM_WEIGHT[dim] ?? 0) * sev) / 5
191
+ S = Math.round(S)
192
+
193
+ let grade = gradeOf(S, thresholds)
194
+
195
+ const degraded = Number(pkg?.__parseErrors ?? 0) > 0
196
+ if (degraded) {
197
+ reasons.push(`degraded:${pkg.__parseErrors}-parse-warning(s)`)
198
+ // 证据不全时不给 A/B:降 C。
199
+ if (grade === 'A' || grade === 'B') grade = 'C'
200
+ }
201
+ return { grade, S, veto: false, degraded, perDim, reasons, c3 }
202
+ }
203
+
204
+ /** 等级 → 动作灯色。B 级已可能含单一危险能力(child_process 单独 28 分 = B),不能判绿。 */
205
+ export function gradeToColor(grade) {
206
+ return grade === 'A' ? 'green'
207
+ : grade === 'B' ? 'blue'
208
+ : grade === 'C' ? 'yellow'
209
+ : 'red'
210
+ }
211
+
212
+ /**
213
+ * Resolve a plugin-relative file while proving it stays inside the plugin root.
214
+ * Absolute candidates, `..` escapes and symlinks pointing outside are rejected.
215
+ * Returns the checked absolute path, or null when the candidate is unsafe/missing.
216
+ */
217
+ export function resolvePluginFile(rootDir, candidate) {
218
+ const root = path.resolve(String(rootDir ?? ''))
219
+ const rel = String(candidate ?? '')
220
+ if (!rel || path.isAbsolute(rel)) return null
221
+ const target = path.resolve(root, rel)
222
+ const inside = (from, to) => {
223
+ const r = path.relative(from, to)
224
+ return r === '' || (!r.startsWith('..' + path.sep) && r !== '..' && !path.isAbsolute(r))
225
+ }
226
+ if (!inside(root, target)) return null
227
+ try {
228
+ const realRoot = fs.realpathSync(root)
229
+ const realTarget = fs.realpathSync(target)
230
+ if (!inside(realRoot, realTarget)) return null
231
+ const st = fs.statSync(target)
232
+ if (!st.isFile()) return null
233
+ return target
234
+ } catch {
235
+ return null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 入口候选顺序(评分与 healer 共用):manifest.entry → exports["."] → module → main
241
+ * → index.js → lib/index.js → plugin.js。
242
+ */
243
+ export function entryCandidates(pkg, manifest = null) {
244
+ const out = []
245
+ const push = (v) => { if (typeof v === 'string' && v.length > 0) out.push(v) }
246
+ push(manifest?.entry)
247
+ const exp = pkg?.exports
248
+ if (typeof exp === 'string') push(exp)
249
+ else if (exp && typeof exp === 'object') {
250
+ const dot = exp['.']
251
+ if (typeof dot === 'string') push(dot)
252
+ else if (dot && typeof dot === 'object') {
253
+ push(dot.import)
254
+ push(dot.default)
255
+ }
256
+ }
257
+ push(pkg?.module)
258
+ push(pkg?.main)
259
+ push('index.js')
260
+ push('lib/index.js')
261
+ push('plugin.js')
262
+ return out
263
+ }
264
+
265
+ /**
266
+ * Deep directory scan: entry + all local .js/.mjs/.cjs files (bounded), imports and
267
+ * a small call-layer signal set. It is still not an AST/call graph and says so via
268
+ * coverage/degradedReasons instead of pretending completeness.
269
+ */
270
+ const DEEP_MAX_FILES = 200
271
+ const DEEP_MAX_BYTES = 2 * 1024 * 1024
272
+ const DEEP_MAX_FILE_BYTES = 512 * 1024
273
+
274
+ function listDeepFiles(root) {
275
+ const files = []
276
+ const reasons = []
277
+ let bytes = 0
278
+ let skipped = 0
279
+ let truncated = false
280
+ const walk = (dir, relBase) => {
281
+ let entries
282
+ try {
283
+ entries = fs.readdirSync(dir, { withFileTypes: true })
284
+ } catch (e) {
285
+ skipped += 1
286
+ reasons.push(`readdir-failed:${relBase || '.'}:${String(e?.message ?? e)}`)
287
+ return
288
+ }
289
+ for (const entry of entries) {
290
+ if (truncated) return
291
+ if (entry.isSymbolicLink()) { skipped += 1; continue }
292
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name
293
+ if (entry.isDirectory()) {
294
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
295
+ walk(path.join(dir, entry.name), rel)
296
+ continue
297
+ }
298
+ if (!entry.isFile() || !/\.(?:mjs|cjs|js)$/i.test(entry.name)) continue
299
+ const abs = resolvePluginFile(root, rel)
300
+ if (!abs) { skipped += 1; reasons.push(`unsafe-or-missing:${rel}`); continue }
301
+ let size = 0
302
+ try { size = fs.statSync(abs).size } catch { skipped += 1; reasons.push(`stat-failed:${rel}`); continue }
303
+ if (size > DEEP_MAX_FILE_BYTES) { skipped += 1; reasons.push(`file-too-large:${rel}`); continue }
304
+ if (files.length >= DEEP_MAX_FILES || bytes + size > DEEP_MAX_BYTES) {
305
+ truncated = true
306
+ break
307
+ }
308
+ files.push({ rel, abs })
309
+ bytes += size
310
+ }
311
+ }
312
+ walk(root, '')
313
+ return { files, skipped, truncated, reasons }
314
+ }
315
+
316
+ function scoreDeepDir(root, pkg, manifest, settings, basis) {
317
+ let entry = null
318
+ for (const candidate of entryCandidates(pkg, manifest)) {
319
+ const file = resolvePluginFile(root, candidate)
320
+ if (file) {
321
+ try {
322
+ if (fs.statSync(file).isFile()) { entry = { rel: candidate, abs: file }; break }
323
+ } catch { /* next */ }
324
+ }
325
+ }
326
+ if (!entry) return null
327
+ const listed = listDeepFiles(root)
328
+ const files = listed.files.some((file) => file.rel === entry.rel)
329
+ ? listed.files
330
+ : [{ rel: entry.rel, abs: entry.abs }, ...listed.files]
331
+ const scanSignals = []
332
+ const callSites = []
333
+ const scanWarnings = []
334
+ const reasons = [...listed.reasons]
335
+ let imports = 0
336
+ let scannedFiles = 0
337
+ for (const file of files) {
338
+ let source = ''
339
+ try {
340
+ source = fs.readFileSync(file.abs, 'utf8')
341
+ } catch (e) {
342
+ reasons.push(`read-failed:${file.rel}:${String(e?.message ?? e)}`)
343
+ continue
344
+ }
345
+ try {
346
+ const deep = scanDeep(source)
347
+ scannedFiles += 1
348
+ imports += deep.imports.length
349
+ scanWarnings.push(...deep.warnings.map((warning) => ({ ...warning, file: file.rel })))
350
+ for (const site of deep.callSites ?? []) callSites.push({ ...site, file: file.rel })
351
+ scanSignals.push(...deep.signals)
352
+ } catch (e) {
353
+ reasons.push(`scan-failed:${file.rel}:${String(e?.message ?? e)}`)
354
+ }
355
+ }
356
+ const degradedReasons = [...new Set(reasons)]
357
+ const scored = scoreScan(scanSignals, { ...(pkg ?? {}), __parseErrors: scanWarnings.length }, settings.thresholds)
358
+ const truncated = listed.truncated || listed.skipped > 0 || degradedReasons.length > 0
359
+ let grade = scored.grade
360
+ if (truncated && (grade === 'A' || grade === 'B')) grade = 'C'
361
+ const degraded = scored.degraded || truncated
362
+ return {
363
+ grade,
364
+ S: scored.S,
365
+ color: gradeToColor(grade),
366
+ veto: scored.veto,
367
+ degraded,
368
+ signals: scanSignals.map((signal) => ({ id: signal.id, weight: signal.weight })),
369
+ reasons: [...scored.reasons, ...degradedReasons.map((reason) => `deep-degraded:${reason}`)],
370
+ perDim: scored.perDim,
371
+ c3: scored.c3,
372
+ coverage: 'deep-import+call',
373
+ imports,
374
+ warnings: scanWarnings.length,
375
+ callSites,
376
+ scannedFiles,
377
+ skippedFiles: listed.skipped,
378
+ truncated: listed.truncated,
379
+ degradedReasons,
380
+ dir: root,
381
+ entry: entry.rel,
382
+ basis,
383
+ }
384
+ }
385
+
386
+
387
+ function scoreSourceImpl(source, pkg = {}, opts = {}) {
388
+ const environment = environmentOf(opts, ROLE.MOTHER)
389
+ const bands = resolveBands(environment, opts && typeof opts === 'object' ? opts.thresholds : null)
390
+ const basis = basisOf(environment, bands)
391
+ const text = typeof source === 'string' ? source : ''
392
+
393
+ let scan
394
+ try {
395
+ scan = opts && opts.deep === true ? scanDeep(text) : scanSource(text)
396
+ } catch (e) {
397
+ return {
398
+ grade: 'E', S: 100, color: 'red', veto: false, degraded: true,
399
+ signals: [], reasons: [`scan-error:${e?.message ?? e}`],
400
+ coverage: 'none', imports: 0, warnings: 0, error: 'scan-error',
401
+ basis,
402
+ }
403
+ }
404
+ const mappedSignals = scan.signals ?? importsToSignals(scan, pkg).signals
405
+ const coverage = scan.coverage ?? 'import-only'
406
+ const scored = scoreScan(mappedSignals, { ...(pkg ?? {}), __parseErrors: scan.warnings.length }, bands)
407
+ return {
408
+ grade: scored.grade,
409
+ S: scored.S,
410
+ color: gradeToColor(scored.grade),
411
+ veto: scored.veto,
412
+ degraded: scored.degraded,
413
+ signals: mappedSignals.map(s => ({ id: s.id, weight: s.weight })),
414
+ reasons: scored.reasons,
415
+ // 附加证据(契约允许附加字段,编排器/UI 可直接用)
416
+ perDim: scored.perDim,
417
+ c3: scored.c3,
418
+ coverage,
419
+ imports: scan.imports.length,
420
+ warnings: scan.warnings.length,
421
+ ...(scan.callSites ? { callSites: scan.callSites } : {}),
422
+ ...(opts && opts.deep === true ? { scannedFiles: 1 } : {}),
423
+ basis,
424
+ }
425
+ }
426
+
427
+ function scoreIntentImpl(intent = {}, opts = {}) {
428
+ const environment = environmentOf(opts, ROLE.MOTHER)
429
+ const bands = resolveBands(environment, opts && typeof opts === 'object' ? opts.thresholds : null)
430
+ const notes = []
431
+ let light
432
+ if (intent.scope === 'system' || intent.scope === 'process') {
433
+ light = 'red'
434
+ notes.push('system-scope')
435
+ } else if (intent.originLight) {
436
+ light = intent.originLight
437
+ notes.push('inherited-originLight')
438
+ } else if (intent.scope === 'session') {
439
+ light = 'yellow'
440
+ notes.push('session-scope')
441
+ } else if (Array.isArray(intent.digests) && intent.digests.length > 0) {
442
+ light = 'blue'
443
+ notes.push('digest-bound')
444
+ } else {
445
+ light = 'yellow'
446
+ notes.push('unbound-digest')
447
+ }
448
+ return { light, notes, basis: basisOf(environment, bands) }
449
+ }
450
+
451
+ function safeGet(ctx, key) {
452
+ try { return ctx?.get?.(key) } catch { return undefined }
453
+ }
454
+
455
+ export const ADMISSION_DEFAULT_MAX_GRADE = 'B'
456
+ const GRADE_RANK = Object.freeze({ A: 1, B: 2, C: 3, D: 4, E: 5 })
457
+
458
+ /**
459
+ * v3.3.0:把「主城门禁」的结构化 findings 折算成 A–E 准入分。
460
+ * 约定:blocker 直接 E;warning 每项 10 分;info 不扣分。
461
+ * 默认只放行 A/B(maxGrade='B'),C 及以上阻断——与 healer 静态门禁同一套分档。
462
+ */
463
+ export function scoreGateFindings(findings, options = {}) {
464
+ const list = Array.isArray(findings) ? findings : []
465
+ const maxGrade = String(options.maxGrade ?? ADMISSION_DEFAULT_MAX_GRADE).toUpperCase()
466
+ if (!GRADE_RANK[maxGrade]) throw new Error('invalid maxGrade: ' + maxGrade)
467
+ const blockers = list.filter((item) => item && item.severity === 'blocker')
468
+ const warnings = list.filter((item) => item && item.severity === 'warning')
469
+ const infos = list.filter((item) => item && item.severity === 'info')
470
+ // 0 warning = A;1–2 个 = B;3 个起 = C(需修复后重跑)。
471
+ const S = blockers.length > 0
472
+ ? 100
473
+ : warnings.length === 0 ? 0 : Math.min(100, 20 + warnings.length * 10)
474
+ const grade = gradeOf(S, BAND_DEFAULTS.mother)
475
+ const reasons = []
476
+ if (blockers.length > 0) reasons.push('blockers=' + blockers.length)
477
+ if (warnings.length > 0) reasons.push('warnings=' + warnings.length)
478
+ if (GRADE_RANK[grade] > GRADE_RANK[maxGrade]) reasons.push('grade=' + grade + '>' + maxGrade)
479
+ return {
480
+ ok: blockers.length === 0 && GRADE_RANK[grade] <= GRADE_RANK[maxGrade],
481
+ grade,
482
+ S,
483
+ maxGrade,
484
+ blockers: blockers.length,
485
+ warnings: warnings.length,
486
+ infos: infos.length,
487
+ reasons,
488
+ basis: basisOf(ROLE.MOTHER, BAND_DEFAULTS.mother),
489
+ }
490
+ }
491
+
492
+ export function apply(ctx, config) {
493
+ const gate = roleGate('scorer', { cfgRole: config?.role, logger: ctx.logger })
494
+ // v2 部署矩阵:scorer 在 mother 与 fork 都激活;这里只守住“未部署角色不得注册服务”。
495
+ if (!gate.active) return
496
+
497
+ const cfg = Config(config ?? {})
498
+ const thresholds = normalizeThresholds(cfg.thresholds)
499
+ // 本实例的部署环境:fork 进程(DSH_FANG_ROLE=fork)里缺省用更宽的分身阈值。
500
+ const environment = gate.role
501
+ const policy = {
502
+ allowlist: Array.isArray(cfg.allowlist) ? cfg.allowlist.map(String) : [],
503
+ // allowlist is policy evidence only: it never lowers the static risk score.
504
+ allowlistUsedForScoring: false,
505
+ environment,
506
+ thresholds,
507
+ }
508
+
509
+ const audit = (kind, data) => {
510
+ try { safeGet(ctx, SERVICE.auditor)?.append?.(kind, data) } catch { /* 审计缺失不得影响评分 */ }
511
+ }
512
+
513
+ /** opts.environment 优先;缺省用本实例部署环境。可选的临时 bands 只能收紧,不能放宽。 */
514
+ const settingsFor = (opts) => {
515
+ const env = environmentOf(opts, environment)
516
+ const base = resolveBands(env, thresholds)
517
+ const requested = opts && typeof opts === 'object' ? opts.thresholds : null
518
+ if (!requested || typeof requested !== 'object') {
519
+ return { environment: env, thresholds: base, deep: opts?.deep === true }
520
+ }
521
+ const candidate = resolveBands(env, requested)
522
+ const tightened = {
523
+ a: Math.min(base.a, candidate.a),
524
+ b: Math.min(base.b, candidate.b),
525
+ c: Math.min(base.c, candidate.c),
526
+ d: Math.min(base.d, candidate.d),
527
+ }
528
+ return { environment: env, thresholds: tightened, deep: opts?.deep === true }
529
+ }
530
+
531
+ const service = {
532
+ policy,
533
+
534
+ /** 本实例部署环境(mother|fork)。 */
535
+ environment,
536
+
537
+ /** 已归一化的 {mother,fork} 阈值(只读副本)。 */
538
+ thresholds: { mother: { ...thresholds.mother }, fork: { ...thresholds.fork } },
539
+
540
+ /** 查询某环境的分档表(补充方法)。 */
541
+ thresholdsFor(environmentName) {
542
+ return resolveBands(normalizeEnvironment(environmentName, environment), thresholds)
543
+ },
544
+
545
+ scoreSource(source, pkg = {}, opts = {}) {
546
+ const out = scoreSourceImpl(source, pkg ?? {}, settingsFor(opts))
547
+ // Hot path stays silent by default; callers that need an audit trail can
548
+ // opt in without making every umbrella source score write a ledger row.
549
+ if (opts && typeof opts === 'object' && opts.audit === true) {
550
+ audit(AUDIT.SCORE, {
551
+ component: name,
552
+ scope: 'source',
553
+ grade: out.grade,
554
+ S: out.S,
555
+ degraded: out.degraded,
556
+ environment: out.basis?.environment ?? null,
557
+ signals: out.signals.map(s => s.id),
558
+ })
559
+ }
560
+ return out
561
+ },
562
+
563
+ scoreDir(dir, manifest = null, opts = {}) {
564
+ const settings = settingsFor(opts)
565
+ const basis = basisOf(settings.environment, resolveBands(settings.environment, settings.thresholds))
566
+ const abs = path.resolve(String(dir ?? ''))
567
+ const pkgFile = resolvePluginFile(abs, 'package.json')
568
+ const pkg = pkgFile ? (readJson(pkgFile, null) ?? {}) : {}
569
+ if (settings.deep === true) {
570
+ const deep = scoreDeepDir(abs, pkg, manifest, settings, basis)
571
+ if (deep) {
572
+ audit(AUDIT.SCORE, {
573
+ component: name,
574
+ dir: abs,
575
+ entry: deep.entry,
576
+ grade: deep.grade,
577
+ S: deep.S,
578
+ veto: deep.veto,
579
+ degraded: deep.degraded,
580
+ environment: deep.basis.environment,
581
+ coverage: deep.coverage,
582
+ scannedFiles: deep.scannedFiles,
583
+ signals: deep.signals.map(s => s.id),
584
+ })
585
+ return deep
586
+ }
587
+ }
588
+ let src = null
589
+ let entry = null
590
+ for (const candidate of entryCandidates(pkg, manifest)) {
591
+ const file = resolvePluginFile(abs, candidate)
592
+ if (!file) continue
593
+ try {
594
+ const st = fs.statSync(file)
595
+ if (!st.isFile()) continue
596
+ src = fs.readFileSync(file, 'utf8')
597
+ entry = candidate
598
+ break
599
+ } catch { /* 试下一个候选 */ }
600
+ }
601
+ if (src === null) {
602
+ const missing = {
603
+ grade: 'E', S: 100, color: 'red', veto: false, degraded: true,
604
+ signals: [], reasons: ['no-entry-file'], perDim: {},
605
+ coverage: 'none', imports: 0, warnings: 0, error: 'no-entry-file',
606
+ dir: abs, entry: null, basis,
607
+ }
608
+ audit(AUDIT.SCORE, { component: name, dir: abs, grade: 'E', error: 'no-entry-file', environment: settings.environment })
609
+ return missing
610
+ }
611
+ const scored = scoreSourceImpl(src, pkg, settings)
612
+ const result = { ...scored, dir: abs, entry }
613
+ audit(AUDIT.SCORE, {
614
+ component: name,
615
+ dir: abs,
616
+ entry,
617
+ grade: result.grade,
618
+ S: result.S,
619
+ veto: result.veto,
620
+ degraded: result.degraded,
621
+ environment: result.basis.environment,
622
+ signals: result.signals.map(s => s.id),
623
+ })
624
+ return result
625
+ },
626
+
627
+ scoreIntent(intent = {}, opts = {}) {
628
+ const settings = settingsFor(opts)
629
+ const out = scoreIntentImpl(intent ?? {}, settings)
630
+ audit(AUDIT.SCORE, {
631
+ component: name,
632
+ intent: out.light,
633
+ scope: intent?.scope ?? null,
634
+ environment: out.basis.environment,
635
+ })
636
+ return out
637
+ },
638
+ }
639
+
640
+ ctx.effect(() => ctx.provide(SERVICE.scorer, service))
641
+ }
642
+
643
+ /** 供测试/其他插件直接使用的纯函数与常量(服务方法之外)。 */
644
+ export const _internals = {
645
+ scoreScan,
646
+ dedupeByFamily,
647
+ gradeToColor,
648
+ gradeOf,
649
+ familyOf,
650
+ entryCandidates,
651
+ scoreSourceImpl,
652
+ scoreIntentImpl,
653
+ normalizeBands,
654
+ normalizeThresholds,
655
+ resolveBands,
656
+ resolvePluginFile,
657
+ basisOf,
658
+ environmentOf,
659
+ normalizeEnvironment,
660
+ BAND_DEFAULTS,
661
+ }