(plugins: readonly LoadedPlugin[])
| 175 | * @returns Set of pluginIds to demote, plus errors for `/doctor` |
| 176 | */ |
| 177 | export function verifyAndDemote(plugins: readonly LoadedPlugin[]): { |
| 178 | demoted: Set<string> |
| 179 | errors: PluginError[] |
| 180 | } { |
| 181 | const known = new Set(plugins.map(p => p.source)) |
| 182 | const enabled = new Set(plugins.filter(p => p.enabled).map(p => p.source)) |
| 183 | // Name-only indexes for bare deps from --plugin-dir (@inline) plugins: |
| 184 | // the real marketplace is unknown, so match "B" against any enabled "B@*". |
| 185 | // enabledByName is a multiset: if B@epic AND B@other are both enabled, |
| 186 | // demoting one mustn't make "B" disappear from the index. |
| 187 | const knownByName = new Set( |
| 188 | plugins.map(p => parsePluginIdentifier(p.source).name), |
| 189 | ) |
| 190 | const enabledByName = new Map<string, number>() |
| 191 | for (const id of enabled) { |
| 192 | const n = parsePluginIdentifier(id).name |
| 193 | enabledByName.set(n, (enabledByName.get(n) ?? 0) + 1) |
| 194 | } |
| 195 | const errors: PluginError[] = [] |
| 196 | |
| 197 | let changed = true |
| 198 | while (changed) { |
| 199 | changed = false |
| 200 | for (const p of plugins) { |
| 201 | if (!enabled.has(p.source)) continue |
| 202 | for (const rawDep of p.manifest.dependencies ?? []) { |
| 203 | const dep = qualifyDependency(rawDep, p.source) |
| 204 | // Bare dep ← @inline plugin: match by name only (see enabledByName) |
| 205 | const isBare = !parsePluginIdentifier(dep).marketplace |
| 206 | const satisfied = isBare |
| 207 | ? (enabledByName.get(dep) ?? 0) > 0 |
| 208 | : enabled.has(dep) |
| 209 | if (!satisfied) { |
| 210 | enabled.delete(p.source) |
| 211 | const count = enabledByName.get(p.name) ?? 0 |
| 212 | if (count <= 1) enabledByName.delete(p.name) |
| 213 | else enabledByName.set(p.name, count - 1) |
| 214 | errors.push({ |
| 215 | type: 'dependency-unsatisfied', |
| 216 | source: p.source, |
| 217 | plugin: p.name, |
| 218 | dependency: dep, |
| 219 | reason: (isBare ? knownByName.has(dep) : known.has(dep)) |
| 220 | ? 'not-enabled' |
| 221 | : 'not-found', |
| 222 | }) |
| 223 | changed = true |
| 224 | break |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | const demoted = new Set( |
| 231 | plugins.filter(p => p.enabled && !enabled.has(p.source)).map(p => p.source), |
| 232 | ) |
| 233 | return { demoted, errors } |
| 234 | } |
no test coverage detected