@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 0.4.0
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +734 -2
- package/bin/dsh-plugin-analyze.mjs +378 -0
- package/bin/dsh-plugin-lint.mjs +381 -0
- package/bin/dsh-plugin-matrix.mjs +78 -0
- package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
- package/package.json +62 -4
- package/schema/dsh-manifest.schema.json +134 -0
- package/scripts/lib/probe.mjs +34 -0
- package/scripts/probe-boot.mjs +123 -0
- package/src/composition.js +1 -0
- package/src/core/baseline.js +98 -0
- package/src/core/changed.js +41 -0
- package/src/core/load.js +145 -0
- package/src/core/mask.js +207 -0
- package/src/core/pack.js +95 -0
- package/src/core/packlist.js +186 -0
- package/src/core/peer.js +39 -0
- package/src/core/profile-scan.js +121 -0
- package/src/core/report.js +174 -0
- package/src/core/rules.js +581 -0
- package/src/core/semver.js +192 -0
- package/src/core/tarball.js +90 -0
- package/src/data/event-types.json +393 -0
- package/src/experimental/conflicts.js +315 -0
- package/src/experimental/index.js +4 -0
- package/src/experimental/performance.js +515 -0
- package/src/experimental/security.js +462 -0
- package/src/index.js +74 -0
- package/src/load.js +1 -0
- package/src/mask.js +1 -0
- package/src/matrix/cache.js +19 -0
- package/src/matrix/cell.js +79 -0
- package/src/matrix/cells.js +58 -0
- package/src/matrix/conflicts.js +210 -0
- package/src/matrix/export.js +155 -0
- package/src/matrix/index.js +71 -0
- package/src/matrix/manifest.js +17 -0
- package/src/matrix/migrate.js +41 -0
- package/src/matrix/run.js +241 -0
- package/src/matrix/sanitize.js +42 -0
- package/src/matrix/schema.js +178 -0
- package/src/matrix/site.js +61 -0
- package/src/matrix/sync.js +72 -0
- package/src/matrix/verify.js +152 -0
- package/src/performance.js +1 -0
- package/src/probe/artifact-resolver.js +161 -0
- package/src/probe/classify.js +8 -0
- package/src/probe/dsh-adapter.js +212 -0
- package/src/probe/environment/docker.js +270 -0
- package/src/probe/environment/host-unsafe.js +39 -0
- package/src/probe/environment/materialize.js +22 -0
- package/src/probe/environment.js +50 -0
- package/src/probe/installed-artifact.js +17 -0
- package/src/probe/probe.js +278 -0
- package/src/report.js +1 -0
- package/src/rules.js +1 -0
- package/src/security.js +1 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/hash.js +31 -0
- package/src/shared/status.js +12 -0
- package/src/shared/validate.js +14 -0
- package/src/shared/version.js +4 -0
- package/src/upgrade/compatibility.js +14 -0
- package/src/upgrade/planner.js +119 -0
- package/src/upgrade/profile.js +82 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition conflict detection: finds static collisions when multiple plugins
|
|
3
|
+
* compete for the same resource (slot, route, tool name, config key).
|
|
4
|
+
*
|
|
5
|
+
* EXPERIMENTAL. This is a cheap pre-flight scan of literal call sites. The
|
|
6
|
+
* authoritative conflict data comes from the runtime matrix runner
|
|
7
|
+
* (scripts/matrix/conflicts.mjs), which installs the plugins together and reads
|
|
8
|
+
* what the host actually registered. This module only understands literals, so a
|
|
9
|
+
* missing conflict is not proof that two plugins compose.
|
|
10
|
+
*
|
|
11
|
+
* @module composition
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { blockAfter } from '../core/mask.js';
|
|
15
|
+
import { EXPERIMENTAL_SCHEMA_VERSION } from '../shared/version.js';
|
|
16
|
+
|
|
17
|
+
const LIMITS = [
|
|
18
|
+
'Static scan of literal call sites: dynamic resource names are not resolved.',
|
|
19
|
+
'The runtime matrix conflict check (scripts/matrix/conflicts.mjs) is the authority; this module is a pre-flight hint.',
|
|
20
|
+
'A missing conflict is not proof that two plugins compose.',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const pluginName = (pkg) => pkg.name ?? pkg.manifest?.name ?? pkg.dir ?? 'unknown';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detect conflicts across a set of loaded packages.
|
|
27
|
+
* @param {Array<object>} packages - objects shaped like loadPackage() results
|
|
28
|
+
* @returns {object} conflict report with categories
|
|
29
|
+
*/
|
|
30
|
+
export function detectConflicts(packages) {
|
|
31
|
+
const conflicts = {
|
|
32
|
+
schemaVersion: EXPERIMENTAL_SCHEMA_VERSION,
|
|
33
|
+
slots: [],
|
|
34
|
+
toolNames: [],
|
|
35
|
+
routes: [],
|
|
36
|
+
unknownRoutes: [],
|
|
37
|
+
configKeys: [],
|
|
38
|
+
eventListeners: [],
|
|
39
|
+
summary: { total: 0, high: 0, medium: 0, low: 0 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Track claimed resources. A plugin that mentions the same resource twice is
|
|
43
|
+
// one claim, not a conflict with itself.
|
|
44
|
+
const slotClaims = new Map();
|
|
45
|
+
const toolClaims = new Map();
|
|
46
|
+
const routeClaims = new Map();
|
|
47
|
+
const configClaims = new Map();
|
|
48
|
+
const waterfallListeners = new Map();
|
|
49
|
+
|
|
50
|
+
const claim = (map, key, entry) => {
|
|
51
|
+
if (!map.has(key)) map.set(key, []);
|
|
52
|
+
const claims = map.get(key);
|
|
53
|
+
if (!claims.some((existing) => existing.plugin === entry.plugin)) claims.push(entry);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (const pkg of packages) {
|
|
57
|
+
const name = pluginName(pkg);
|
|
58
|
+
for (const slot of extractSlots(pkg)) claim(slotClaims, slot.name, { plugin: name, ...slot });
|
|
59
|
+
for (const tool of extractTools(pkg)) claim(toolClaims, tool.name, { plugin: name, ...tool });
|
|
60
|
+
for (const route of extractRoutes(pkg)) {
|
|
61
|
+
if (route.method === 'unknown') conflicts.unknownRoutes.push({ plugin: name, ...route });
|
|
62
|
+
else claim(routeClaims, route.method + ' ' + route.path, { plugin: name, ...route });
|
|
63
|
+
}
|
|
64
|
+
for (const cfg of extractConfigKeys(pkg)) claim(configClaims, cfg.key, { plugin: name, ...cfg });
|
|
65
|
+
for (const wf of extractWaterfallListeners(pkg)) claim(waterfallListeners, wf.event, { plugin: name, ...wf });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const [slot, claims] of slotClaims) {
|
|
69
|
+
if (claims.length > 1) {
|
|
70
|
+
conflicts.slots.push({
|
|
71
|
+
resource: slot,
|
|
72
|
+
plugins: claims.map((c) => c.plugin),
|
|
73
|
+
risk: assessSlotRisk(slot),
|
|
74
|
+
resolution: suggestSlotResolution(slot, claims),
|
|
75
|
+
details: claims,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const [name, claims] of toolClaims) {
|
|
81
|
+
if (claims.length > 1) {
|
|
82
|
+
conflicts.toolNames.push({
|
|
83
|
+
resource: name,
|
|
84
|
+
plugins: claims.map((c) => c.plugin),
|
|
85
|
+
risk: 'high',
|
|
86
|
+
resolution: 'Use unique tool names with a plugin prefix (for example "myplugin:search"). A duplicate tool name can make the later plugin fail to mount.',
|
|
87
|
+
details: claims,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [resource, claims] of routeClaims) {
|
|
93
|
+
if (claims.length > 1) {
|
|
94
|
+
conflicts.routes.push({
|
|
95
|
+
resource,
|
|
96
|
+
path: claims[0].path,
|
|
97
|
+
methods: [...new Set(claims.map((claim) => claim.method))],
|
|
98
|
+
plugins: claims.map((c) => c.plugin),
|
|
99
|
+
risk: 'medium',
|
|
100
|
+
resolution: 'Register a namespaced route instead of sharing a method and path.',
|
|
101
|
+
details: claims,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [key, claims] of configClaims) {
|
|
107
|
+
if (claims.length > 1) {
|
|
108
|
+
conflicts.configKeys.push({
|
|
109
|
+
resource: key,
|
|
110
|
+
plugins: claims.map((c) => c.plugin),
|
|
111
|
+
risk: assessConfigRisk(key),
|
|
112
|
+
resolution: 'Use plugin-namespaced config keys.',
|
|
113
|
+
details: claims,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const [event, listeners] of waterfallListeners) {
|
|
119
|
+
if (listeners.length < 2) continue;
|
|
120
|
+
const problematic = listeners.filter((listener) => listener.mayFallThrough);
|
|
121
|
+
if (problematic.length === 0) continue;
|
|
122
|
+
conflicts.eventListeners.push({
|
|
123
|
+
resource: event,
|
|
124
|
+
plugins: listeners.map((listener) => listener.plugin),
|
|
125
|
+
risk: 'medium',
|
|
126
|
+
resolution: 'Check listener order and preserve next(): a waterfall listener that neither returns next() nor rejects deliberately can swallow the decision.',
|
|
127
|
+
problematic: problematic.map((entry) => entry.plugin),
|
|
128
|
+
details: listeners,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const allConflicts = [
|
|
133
|
+
...conflicts.slots,
|
|
134
|
+
...conflicts.toolNames,
|
|
135
|
+
...conflicts.routes,
|
|
136
|
+
...conflicts.configKeys,
|
|
137
|
+
...conflicts.eventListeners,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
conflicts.summary.total = allConflicts.length;
|
|
141
|
+
for (const conflict of allConflicts) {
|
|
142
|
+
if (conflict.risk === 'high') conflicts.summary.high++;
|
|
143
|
+
else if (conflict.risk === 'medium') conflicts.summary.medium++;
|
|
144
|
+
else conflicts.summary.low++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
conflicts.experimental = true;
|
|
148
|
+
conflicts.limits = LIMITS;
|
|
149
|
+
return conflicts;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Extract UI slot registrations from package code (literal names only). */
|
|
153
|
+
function extractSlots(pkg) {
|
|
154
|
+
const slots = [];
|
|
155
|
+
const patterns = [
|
|
156
|
+
// Current API: ctx.slots.inject('<slot>', () => ctx.slots.register({ name: '<slot>', ... }))
|
|
157
|
+
/ctx\s*\.\s*slots\s*\.\s*inject\s*\(\s*['"]([^'"]+)['"]/g,
|
|
158
|
+
/ctx\s*\.\s*slots\s*\.\s*register\s*\(\s*\{[^}]*?\bname\s*:\s*['"]([^'"]+)['"]/g,
|
|
159
|
+
// Older/alternate shapes still seen in community plugins.
|
|
160
|
+
/\bslot\s*:\s*['"]([^'"]+)['"]/g,
|
|
161
|
+
/ctx\s*\.\s*provide\s*\(\s*['"]ui\.slot\.([^'"]+)['"]/g,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
for (const file of pkg.jsFiles) {
|
|
165
|
+
const view = file.masked ?? file.text;
|
|
166
|
+
patterns.forEach((pattern, index) => {
|
|
167
|
+
pattern.lastIndex = 0;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = pattern.exec(view)) !== null) {
|
|
170
|
+
slots.push({
|
|
171
|
+
name: index === 3 ? 'ui.slot.' + m[1] : m[1],
|
|
172
|
+
file: file.path,
|
|
173
|
+
line: lineOfMatch(file.lineStarts, m.index),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return slots;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Extract tool definitions (literal names only). */
|
|
183
|
+
function extractTools(pkg) {
|
|
184
|
+
const tools = [];
|
|
185
|
+
const patterns = [
|
|
186
|
+
// Current API: ctx.tools.register(defineTool({ name: '...' })) or the object form.
|
|
187
|
+
/ctx\s*\.\s*tools\s*\.\s*register\s*\(\s*(?:defineTool\s*\(\s*)?\{[^}]*?\bname\s*:\s*['"]([^'"]+)['"]/g,
|
|
188
|
+
// Declarative arrays: tools: [{ name: '...' }]
|
|
189
|
+
/tools\s*:\s*\[[^\]]*?\{[^}]*?\bname\s*:\s*['"]([^'"]+)['"]/g,
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const file of pkg.jsFiles) {
|
|
193
|
+
const view = file.masked ?? file.text;
|
|
194
|
+
patterns.forEach((pattern) => {
|
|
195
|
+
pattern.lastIndex = 0;
|
|
196
|
+
let m;
|
|
197
|
+
while ((m = pattern.exec(view)) !== null) {
|
|
198
|
+
tools.push({ name: m[1], file: file.path, line: lineOfMatch(file.lineStarts, m.index) });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return tools;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function extractRoutes(pkg) {
|
|
207
|
+
const routes = [];
|
|
208
|
+
const fetchPattern = /\bconnection\s*\.\s*fetch\s*\.\s*register\s*\(\s*\{([^}]+)\}/g;
|
|
209
|
+
const routerPattern = /ctx\s*\.\s*router\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
210
|
+
|
|
211
|
+
for (const file of pkg.jsFiles) {
|
|
212
|
+
const view = file.masked ?? file.text;
|
|
213
|
+
let match;
|
|
214
|
+
while ((match = fetchPattern.exec(view)) !== null) {
|
|
215
|
+
const body = match[1];
|
|
216
|
+
const path = /\bpath\s*:\s*['"]([^'"]+)['"]/.exec(body)?.[1];
|
|
217
|
+
if (!path) continue;
|
|
218
|
+
const methodsBlock = /\bmethods\s*:\s*\[([^\]]*)\]/.exec(body)?.[1];
|
|
219
|
+
const singular = /\bmethod\s*:\s*['"]([A-Za-z]+)['"]/.exec(body)?.[1];
|
|
220
|
+
const methods = methodsBlock === undefined
|
|
221
|
+
? (singular ? [singular] : [])
|
|
222
|
+
: [...methodsBlock.matchAll(/['"]([A-Za-z]+)['"]/g)].map((entry) => entry[1]);
|
|
223
|
+
const location = { file: file.path, line: lineOfMatch(file.lineStarts, match.index) };
|
|
224
|
+
if (methods.length === 0) routes.push({ path, method: 'unknown', ...location });
|
|
225
|
+
for (const method of methods) routes.push({ path, method: method.toUpperCase(), ...location });
|
|
226
|
+
}
|
|
227
|
+
while ((match = routerPattern.exec(view)) !== null) {
|
|
228
|
+
routes.push({ path: match[2], method: match[1].toUpperCase(), file: file.path, line: lineOfMatch(file.lineStarts, match.index) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return routes;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Extract config key accesses (literal keys only). */
|
|
236
|
+
function extractConfigKeys(pkg) {
|
|
237
|
+
const keys = [];
|
|
238
|
+
const setPattern = /ctx\s*\.\s*config\s*\.\s*set\s*\(\s*['"]([^'"]+)['"]/g;
|
|
239
|
+
|
|
240
|
+
for (const file of pkg.jsFiles) {
|
|
241
|
+
const view = file.masked ?? file.text;
|
|
242
|
+
setPattern.lastIndex = 0;
|
|
243
|
+
let m;
|
|
244
|
+
while ((m = setPattern.exec(view)) !== null) {
|
|
245
|
+
keys.push({ key: m[1], file: file.path, line: lineOfMatch(file.lineStarts, m.index), operation: 'set' });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return keys;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function extractWaterfallListeners(pkg) {
|
|
253
|
+
const listeners = [];
|
|
254
|
+
const waterfallEvents = new Set([
|
|
255
|
+
'agent/pre-step',
|
|
256
|
+
'agent/request',
|
|
257
|
+
'llm/stream',
|
|
258
|
+
'tools/pre-execute',
|
|
259
|
+
'tools/execute',
|
|
260
|
+
'tools/post-execute',
|
|
261
|
+
]);
|
|
262
|
+
const onPattern = /ctx\s*\.\s*on\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/g;
|
|
263
|
+
|
|
264
|
+
for (const file of pkg.jsFiles) {
|
|
265
|
+
const view = file.masked ?? file.text;
|
|
266
|
+
const structural = file.structural ?? view;
|
|
267
|
+
let m;
|
|
268
|
+
while ((m = onPattern.exec(view)) !== null) {
|
|
269
|
+
const event = m[1];
|
|
270
|
+
if (!waterfallEvents.has(event)) continue;
|
|
271
|
+
const block = blockAfter(structural, m.index);
|
|
272
|
+
if (!block) continue;
|
|
273
|
+
const hasDecision = /\bthrow\s+|\breturn\s*[^;\s}]/.test(block.body);
|
|
274
|
+
listeners.push({
|
|
275
|
+
event,
|
|
276
|
+
file: file.path,
|
|
277
|
+
line: lineOfMatch(file.lineStarts, m.index),
|
|
278
|
+
mayFallThrough: !hasDecision,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return listeners;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Assess risk level for a slot collision (heuristic). */
|
|
287
|
+
function assessSlotRisk(slotName) {
|
|
288
|
+
// Slots designed for several providers are expected to be claimed more than once.
|
|
289
|
+
if (slotName === 'settings.section' || slotName === 'conversation.chat.node') return 'low';
|
|
290
|
+
return 'medium';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Assess config key collision risk. */
|
|
294
|
+
function assessConfigRisk(key) {
|
|
295
|
+
if (!key.includes('.')) return 'high';
|
|
296
|
+
if (key.startsWith('theme.') || key.startsWith('appearance.')) return 'low';
|
|
297
|
+
return 'medium';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Suggest a resolution for a slot collision. */
|
|
301
|
+
function suggestSlotResolution(slotName, claims) {
|
|
302
|
+
if (claims.length === 2) {
|
|
303
|
+
return 'Two plugins claim "' + slotName + '": ' + claims.map((c) => c.plugin).join(', ') + '. Check whether both are meant to render there.';
|
|
304
|
+
}
|
|
305
|
+
return claims.length + ' plugins claim "' + slotName + '"; review which should render where.';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Find the line number of a regex match index. */
|
|
309
|
+
function lineOfMatch(lineStarts, index) {
|
|
310
|
+
if (!lineStarts || lineStarts.length === 0) return 1;
|
|
311
|
+
for (let i = 0; i < lineStarts.length; i++) {
|
|
312
|
+
if (lineStarts[i] > index) return i;
|
|
313
|
+
}
|
|
314
|
+
return lineStarts.length;
|
|
315
|
+
}
|