@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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +734 -2
  4. package/bin/dsh-plugin-analyze.mjs +378 -0
  5. package/bin/dsh-plugin-lint.mjs +381 -0
  6. package/bin/dsh-plugin-matrix.mjs +78 -0
  7. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  8. package/package.json +62 -4
  9. package/schema/dsh-manifest.schema.json +134 -0
  10. package/scripts/lib/probe.mjs +34 -0
  11. package/scripts/probe-boot.mjs +123 -0
  12. package/src/composition.js +1 -0
  13. package/src/core/baseline.js +98 -0
  14. package/src/core/changed.js +41 -0
  15. package/src/core/load.js +145 -0
  16. package/src/core/mask.js +207 -0
  17. package/src/core/pack.js +95 -0
  18. package/src/core/packlist.js +186 -0
  19. package/src/core/peer.js +39 -0
  20. package/src/core/profile-scan.js +121 -0
  21. package/src/core/report.js +174 -0
  22. package/src/core/rules.js +581 -0
  23. package/src/core/semver.js +192 -0
  24. package/src/core/tarball.js +90 -0
  25. package/src/data/event-types.json +393 -0
  26. package/src/experimental/conflicts.js +315 -0
  27. package/src/experimental/index.js +4 -0
  28. package/src/experimental/performance.js +515 -0
  29. package/src/experimental/security.js +462 -0
  30. package/src/index.js +74 -0
  31. package/src/load.js +1 -0
  32. package/src/mask.js +1 -0
  33. package/src/matrix/cache.js +19 -0
  34. package/src/matrix/cell.js +79 -0
  35. package/src/matrix/cells.js +58 -0
  36. package/src/matrix/conflicts.js +210 -0
  37. package/src/matrix/export.js +155 -0
  38. package/src/matrix/index.js +71 -0
  39. package/src/matrix/manifest.js +17 -0
  40. package/src/matrix/migrate.js +41 -0
  41. package/src/matrix/run.js +241 -0
  42. package/src/matrix/sanitize.js +42 -0
  43. package/src/matrix/schema.js +178 -0
  44. package/src/matrix/site.js +61 -0
  45. package/src/matrix/sync.js +72 -0
  46. package/src/matrix/verify.js +152 -0
  47. package/src/performance.js +1 -0
  48. package/src/probe/artifact-resolver.js +161 -0
  49. package/src/probe/classify.js +8 -0
  50. package/src/probe/dsh-adapter.js +212 -0
  51. package/src/probe/environment/docker.js +270 -0
  52. package/src/probe/environment/host-unsafe.js +39 -0
  53. package/src/probe/environment/materialize.js +22 -0
  54. package/src/probe/environment.js +50 -0
  55. package/src/probe/installed-artifact.js +17 -0
  56. package/src/probe/probe.js +278 -0
  57. package/src/report.js +1 -0
  58. package/src/rules.js +1 -0
  59. package/src/security.js +1 -0
  60. package/src/shared/errors.js +7 -0
  61. package/src/shared/hash.js +31 -0
  62. package/src/shared/status.js +12 -0
  63. package/src/shared/validate.js +14 -0
  64. package/src/shared/version.js +4 -0
  65. package/src/upgrade/compatibility.js +14 -0
  66. package/src/upgrade/planner.js +119 -0
  67. package/src/upgrade/profile.js +82 -0
@@ -0,0 +1,581 @@
1
+ import { readFileSync, existsSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { appendCallSites, blockAfter, braceDepthAt, lineOfIndex, stringAfter } from './mask.js';
4
+ import { clientExportTarget, isBuildOutput } from './load.js';
5
+ import { evaluatePeers } from './peer.js';
6
+ import { compareVersions, parseVersion } from './semver.js';
7
+ import { globToRegExp, hasPackedFile } from './packlist.js';
8
+
9
+ const CATALOG = JSON.parse(readFileSync(new URL('../data/event-types.json', import.meta.url), 'utf8'));
10
+ export const EVENT_TYPES = new Set(CATALOG.types);
11
+ export const EVENT_CATALOG = { harvestedAt: CATALOG.harvestedAt, dshVersions: CATALOG.dshVersions || [] };
12
+
13
+ /** Upstream documents that ship inside a DSH installation. */
14
+ export const DOC = {
15
+ practices: 'dsh plugin-development / references/practices.md',
16
+ host: 'dsh plugin-development / references/host-plugin.md',
17
+ ui: 'dsh plugin-development / references/ui-plugin.md',
18
+ appBoot: 'dsh app-boot / README.md (peer admission)',
19
+ };
20
+
21
+ const EVENT_TYPE_RE = /^[a-z][a-z0-9-]*\/[a-z0-9-]+$/;
22
+ const HARNESS_CLIENT_PKG = /@deepseek-ai\/dsh-client[a-z0-9-]*/g;
23
+
24
+ /** Whether the harvested catalog covers a target DSH version. */
25
+ function catalogCoverage(target) {
26
+ if (!target) return { covered: true };
27
+ const versions = (CATALOG.dshVersions || []).map((value) => ({ raw: value, parsed: parseVersion(value) })).filter((entry) => entry.parsed);
28
+ if (versions.length === 0) return { covered: false, newest: undefined };
29
+ versions.sort((a, b) => compareVersions(a.parsed, b.parsed));
30
+ const newest = versions[versions.length - 1];
31
+ const parsedTarget = parseVersion(target);
32
+ if (!parsedTarget) return { covered: false, newest: newest.raw };
33
+ return { covered: compareVersions(parsedTarget, newest.parsed) <= 0, newest: newest.raw };
34
+ }
35
+
36
+ export const RULES = [
37
+ {
38
+ id: 'E1',
39
+ level: 'error',
40
+ title: 'appends a session event type that appears nowhere in the official packages',
41
+ doc: DOC.practices,
42
+ check(ctx, options, push) {
43
+ const coverage = catalogCoverage(options.dshVersion);
44
+ for (const file of ctx.jsFiles) {
45
+ for (const site of appendCallSites(file.masked, file.strings)) {
46
+ if (!EVENT_TYPE_RE.test(site.type)) continue;
47
+ if (EVENT_TYPES.has(site.type)) continue;
48
+ push({
49
+ ...(coverage.covered ? {} : { level: 'warn' }),
50
+ file: file.path,
51
+ line: site.line,
52
+ message: 'session.append("' + site.type + '", ...): this type does not appear anywhere in the official Harness packages (' + EVENT_TYPES.size + ' types observed).'
53
+ + (coverage.covered ? '' : ' The catalog covers up to DSH ' + coverage.newest + '; the target ' + options.dshVersion + ' is newer.'),
54
+ evidence: '.append("' + site.type + '", ...)',
55
+ hint: 'The reference asks plugins not to append session events with a new type: a live Session.append() cannot set ignorable: true, so a log holding an unknown type can refuse to reopen. The allowlist is harvested, not authoritative — if this type is legitimate, it is a gap in the rule, not in your plugin.'
56
+ + (coverage.covered ? '' : ' A newer host may have added this type; treat this as a review hint, not an error.'),
57
+ });
58
+ }
59
+ }
60
+ },
61
+ },
62
+
63
+ {
64
+ id: 'E2',
65
+ level: 'error',
66
+ title: 'client bundle factory id matches nothing the loader can ask for',
67
+ doc: DOC.ui,
68
+ check(ctx, options, push) {
69
+ if (ctx.entries.client === undefined || !ctx.exists(ctx.entries.client)) return;
70
+ const file = ctx.byPath(ctx.entries.client);
71
+ if (!file) return;
72
+
73
+ // The loader keys the browser module table by an id and throws when a bundle
74
+ // registers a different one (dsh-client-modules: "bundle … loaded without
75
+ // registering \"id\""). What it asks for is either the package name or a row
76
+ // name from the plugin's own patch, so both are acceptable. Asserting only the
77
+ // package name produced a false accusation against @roarpeng/graphflow, whose
78
+ // patch mounts the row "@roarpeng/graphflow/dsh".
79
+ const acceptable = new Set();
80
+ if (ctx.manifest.name) {
81
+ acceptable.add(ctx.manifest.name);
82
+ // The loader strips a trailing "/client" from a registration id before
83
+ // keying the browser module table (dsh-client-modules: stripClientSuffix).
84
+ acceptable.add(ctx.manifest.name + '/client');
85
+ }
86
+ const patchRows = [];
87
+ if (ctx.entries.patch !== undefined && ctx.exists(ctx.entries.patch)) {
88
+ const patch = ctx.text(ctx.entries.patch) || '';
89
+ for (const m of patch.matchAll(/^\s*name:\s*['"]?([^'"\s#]+)['"]?/gm)) {
90
+ // A patch row resolves through its package specifier; only rows that
91
+ // belong to this package can make the loader ask for this factory id.
92
+ // An unrelated "@scope/other" row is not a legal key.
93
+ if (!ctx.manifest.name) continue;
94
+ if (m[1] === ctx.manifest.name || m[1].startsWith(ctx.manifest.name + '/')) {
95
+ acceptable.add(m[1]);
96
+ patchRows.push(m[1]);
97
+ }
98
+ }
99
+ }
100
+
101
+ const re = /__ModuleLoader__\s*\.\s*load\s*\(\s*\{\s*id\s*:\s*/g;
102
+ let m;
103
+ while ((m = re.exec(file.masked)) !== null) {
104
+ const s = stringAfter(file.strings, m.index + m[0].length, 8);
105
+ if (!s || acceptable.has(s.value)) continue;
106
+ // A computed id (template interpolation) is a static-analysis boundary.
107
+ if (s.value.includes('${')) continue;
108
+ push({
109
+ file: file.path,
110
+ line: s.line,
111
+ message: 'factory id "' + s.value + '" is neither the package name "' + ctx.manifest.name + '" nor a row name in ' + (ctx.entries.patch || 'the bundle patch') + (patchRows.length ? ' (' + [...new Set(patchRows)].join(', ') + ')' : '') + '.',
112
+ evidence: '__ModuleLoader__.load({ id: "' + s.value + '" })',
113
+ hint: 'The browser module table keys the factory by the id it asks for and the loader throws when a bundle registers another one, leaving the client half unreachable.',
114
+ });
115
+ }
116
+ },
117
+ },
118
+
119
+ {
120
+ id: 'E3',
121
+ level: 'error',
122
+ title: 'manifest references a file that is not in the package',
123
+ doc: DOC.host,
124
+ check(ctx, options, push) {
125
+ const declared = [
126
+ ['host entry', ctx.entries.hostDeclared ? ctx.entries.host : undefined, 'exports["."] or manifest.main'],
127
+ ['client entry', ctx.entries.client, 'exports["./client"]'],
128
+ ['bundle patch', ctx.entries.patch, 'dsh.bundle.patch'],
129
+ ['icon', ctx.entries.icon, 'icon'],
130
+ ];
131
+ for (const [label, rel, source] of declared) {
132
+ if (rel === undefined) continue;
133
+ if (ctx.exists(rel)) continue;
134
+ if (ctx.phase === 'author' && ctx.buildScripts && isBuildOutput(rel)) {
135
+ push({
136
+ level: 'info',
137
+ file: 'package.json',
138
+ line: manifestLine(ctx.manifestText, source),
139
+ message: label + ' "' + rel + '" is not built yet; run the build before publishing.',
140
+ evidence: source + ' -> ' + rel + ' (author phase)',
141
+ hint: 'The publish phase treats a missing build output as an error; the author phase reports it as a hint so a pre-build tree is not accused of being broken.',
142
+ });
143
+ continue;
144
+ }
145
+ push({
146
+ file: 'package.json',
147
+ line: manifestLine(ctx.manifestText, source),
148
+ message: label + ' points at "' + rel + '", which does not exist.',
149
+ evidence: source + ' -> ' + rel,
150
+ hint: 'Management pages read these paths without activating the plugin; a missing file is reported as a broken package.',
151
+ });
152
+ }
153
+ // Node and the DSH loader resolve exports["."] first; a stale main that is
154
+ // shadowed by a working exports entry does not break loading, so it is a
155
+ // review hint rather than an error.
156
+ if (ctx.entries.mainDeclared && ctx.entries.main && ctx.entries.main !== ctx.entries.host
157
+ && !ctx.exists(ctx.entries.main)) {
158
+ push({
159
+ level: 'info',
160
+ file: 'package.json',
161
+ line: manifestLine(ctx.manifestText, 'main'),
162
+ message: 'main points at "' + ctx.entries.main + '", which does not exist; exports["."] takes precedence, so loading is unaffected.',
163
+ evidence: 'main -> ' + ctx.entries.main + ' (shadowed by exports["."])',
164
+ hint: 'Remove the stale main or point it at the same file as exports["."]; tools that predate exports may still read it.',
165
+ });
166
+ }
167
+ },
168
+ },
169
+
170
+ {
171
+ id: 'E4',
172
+ level: 'error',
173
+ title: 'declares a client half without a client entry',
174
+ doc: DOC.ui,
175
+ check(ctx, options, push) {
176
+ const hasClientSection = Boolean(ctx.manifest.dsh && ctx.manifest.dsh.client);
177
+ if (!hasClientSection) return;
178
+ // entries.client falls back to client.js so the analysis has a file to read;
179
+ // the platform itself resolves the browser artifact only through the
180
+ // ./client export, and only for a string or a string {default}.
181
+ const declared = clientExportTarget(ctx.manifest.exports || {}, './client');
182
+ const declaredRel = declared === undefined ? undefined : String(declared).replace(/^\.\//, '');
183
+ if (declaredRel !== undefined && ctx.exists(declaredRel)) return;
184
+ if (ctx.phase === 'author' && ctx.buildScripts && declaredRel !== undefined && isBuildOutput(declaredRel)) {
185
+ push({
186
+ level: 'info',
187
+ file: 'package.json',
188
+ line: manifestLine(ctx.manifestText, 'dsh.client'),
189
+ message: 'dsh.client is declared; its client entry "' + declaredRel + '" is not built yet.',
190
+ evidence: 'exports["./client"] -> ' + declaredRel + ' (author phase)',
191
+ hint: 'Run the build before publishing; the publish phase reports the missing artifact as an error.',
192
+ });
193
+ return;
194
+ }
195
+ push({
196
+ file: 'package.json',
197
+ line: manifestLine(ctx.manifestText, 'dsh.client'),
198
+ message: 'dsh.client is declared but exports["./client"] does not resolve to a file.',
199
+ evidence: 'dsh.client present, no ./client export',
200
+ hint: 'The platform resolves the browser artifact through the ./client export.',
201
+ });
202
+ },
203
+ },
204
+
205
+ {
206
+ id: 'E5',
207
+ level: 'error',
208
+ title: 'published files omit an artifact the manifest depends on',
209
+ doc: DOC.host,
210
+ check(ctx, options, push) {
211
+ const artifacts = [
212
+ ['host entry', ctx.entries.hostDeclared ? ctx.entries.host : undefined],
213
+ ['client entry', ctx.entries.client],
214
+ ['bundle patch', ctx.entries.patch],
215
+ ['icon', ctx.entries.icon],
216
+ ];
217
+ for (const [label, rel] of artifacts) {
218
+ if (rel === undefined || !ctx.exists(rel) || !ctx.packlist.complete) continue;
219
+ if (hasPackedFile(ctx.packlist, rel)) continue;
220
+ push({
221
+ file: 'package.json',
222
+ line: manifestLine(ctx.manifestText, '"files"'),
223
+ message: label + ' "' + rel + '" is not covered by "files", so it is absent from the published tarball.',
224
+ evidence: 'files: ' + JSON.stringify(ctx.files),
225
+ hint: 'This class of bug passes local testing and only appears after publish.',
226
+ });
227
+ }
228
+ },
229
+ },
230
+
231
+ {
232
+ id: 'E6',
233
+ level: 'error',
234
+ title: 'a DSH peer range excludes the target runtime',
235
+ doc: DOC.appBoot,
236
+ check(ctx, options, push) {
237
+ if (!options.dshVersion) return;
238
+ const peers = evaluatePeers(ctx.manifest, options.dshVersion);
239
+ if (peers.peers.length === 0) return;
240
+ for (const peer of peers.unsatisfied) {
241
+ push({
242
+ file: 'package.json',
243
+ line: manifestLine(ctx.manifestText, peer.name),
244
+ message: 'peerDependency ' + peer.name + ' "' + peer.range + '" does not accept DSH ' + options.dshVersion + '.',
245
+ evidence: peer.name + ': ' + peer.range,
246
+ hint: 'DSH checks peerDependencies on @deepseek-ai/dsh* before importing a plugin; every declared range must match the running version, and prereleases participate. Widen the range, or grant an exact exemption in the profile compatibility.json.',
247
+ });
248
+ }
249
+ for (const peer of peers.undecidable) {
250
+ push({
251
+ level: 'info',
252
+ file: 'package.json',
253
+ line: manifestLine(ctx.manifestText, peer.name),
254
+ message: 'peerDependency ' + peer.name + ' "' + peer.range + '" is outside the range grammar this rule can decide.',
255
+ evidence: peer.name + ': ' + peer.range,
256
+ hint: 'The host still evaluates this range. Treat a clean run as "not checked", not as "compatible".',
257
+ });
258
+ }
259
+ },
260
+ },
261
+
262
+
263
+ {
264
+ id: 'E7',
265
+ level: 'error',
266
+ title: 'the published artifact contract is broken',
267
+ doc: DOC.host,
268
+ check(ctx, options, push) {
269
+ if (ctx.phase === 'author') return;
270
+ const manifest = ctx.manifest;
271
+
272
+ const exportStrings = (value) => {
273
+ if (typeof value === 'string') return [value];
274
+ if (Array.isArray(value)) return value.flatMap(exportStrings);
275
+ if (value && typeof value === 'object') return Object.values(value).flatMap(exportStrings);
276
+ return [];
277
+ };
278
+ for (const [subpath, value] of Object.entries(manifest.exports || {})) {
279
+ if (subpath === '.' || subpath === './client' || subpath.includes('*')) continue;
280
+ for (const target of exportStrings(value)) {
281
+ if (target.includes('*')) continue;
282
+ const rel = String(target).replace(/^\.\//, '');
283
+ if (ctx.exists(rel)) continue;
284
+ push({
285
+ file: 'package.json',
286
+ line: manifestLine(ctx.manifestText, subpath.replace(/^\.\//, '')),
287
+ message: 'exports["' + subpath + '"] points at "' + rel + '", which does not exist.',
288
+ evidence: 'exports["' + subpath + '"] -> ' + rel,
289
+ hint: 'A subpath export that resolves to nothing fails at import time; publish phase checks the artifact the user installs.',
290
+ });
291
+ }
292
+ }
293
+
294
+ const bins = typeof manifest.bin === 'string'
295
+ ? { [manifest.name || 'bin']: manifest.bin }
296
+ : manifest.bin && typeof manifest.bin === 'object' ? manifest.bin : {};
297
+ for (const [name, target] of Object.entries(bins)) {
298
+ if (typeof target !== 'string') continue;
299
+ const rel = target.replace(/^\.\//, '');
300
+ if (ctx.exists(rel)) continue;
301
+ push({
302
+ file: 'package.json',
303
+ line: manifestLine(ctx.manifestText, name),
304
+ message: 'bin["' + name + '"] points at "' + rel + '", which does not exist.',
305
+ evidence: 'bin.' + name + ' -> ' + rel,
306
+ hint: 'The package manager links the bin at install time; a missing target leaves a broken command.',
307
+ });
308
+ }
309
+
310
+ if (ctx.entries.icon !== undefined && ctx.exists(ctx.entries.icon)) {
311
+ const extension = (/\.([a-z0-9]+)$/i.exec(ctx.entries.icon) || [])[1];
312
+ if (extension && !['svg', 'png', 'jpg', 'jpeg', 'webp'].includes(extension.toLowerCase())) {
313
+ push({
314
+ level: 'warn',
315
+ file: 'package.json',
316
+ line: manifestLine(ctx.manifestText, 'icon'),
317
+ message: 'icon "' + ctx.entries.icon + '" uses an unsupported format; the host accepts SVG, PNG, JPEG, or WebP.',
318
+ evidence: 'icon -> ' + ctx.entries.icon,
319
+ hint: 'Management cards read the icon without activating the plugin.',
320
+ });
321
+ }
322
+ try {
323
+ if (statSync(join(ctx.dir, ctx.entries.icon)).size > 256 * 1024) {
324
+ push({
325
+ level: 'warn',
326
+ file: 'package.json',
327
+ line: manifestLine(ctx.manifestText, 'icon'),
328
+ message: 'icon "' + ctx.entries.icon + '" is larger than 256 KiB; the host may ignore it.',
329
+ evidence: 'icon -> ' + ctx.entries.icon,
330
+ hint: 'The host reads display metadata before activation; keep the icon small.',
331
+ });
332
+ }
333
+ } catch { /* the existence check already covered an unreadable file */ }
334
+ }
335
+
336
+ if (ctx.packlist.complete && Array.isArray(ctx.files)) {
337
+ for (const raw of ctx.files) {
338
+ if (typeof raw !== 'string' || raw.startsWith('!')) continue;
339
+ const pattern = raw.replace(/^\.\//, '').replace(/\/+$/, '');
340
+ if (!pattern) continue;
341
+ const matched = [...ctx.packlist.paths].some((candidate) =>
342
+ candidate === pattern
343
+ || candidate.startsWith(pattern + '/')
344
+ || ((pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) && globToRegExp(pattern).test(candidate)));
345
+ if (matched) continue;
346
+ push({
347
+ level: 'warn',
348
+ file: 'package.json',
349
+ line: manifestLine(ctx.manifestText, '"files"'),
350
+ message: 'files entry "' + raw + '" matches no packed file.',
351
+ evidence: 'files: ' + JSON.stringify(ctx.files),
352
+ hint: 'A stale files entry usually means a path was renamed or a build output was not produced.',
353
+ });
354
+ }
355
+ }
356
+ },
357
+ },
358
+
359
+ {
360
+ id: 'W1',
361
+ level: 'warn',
362
+ title: 'couples the plugin to a Harness Client package',
363
+ doc: DOC.ui,
364
+ check(ctx, options, push) {
365
+ // First-party packages ship in the same release as the browser module table,
366
+ // so the coupling this rule warns about does not apply to them. Measured on
367
+ // 2026-10-13: every official client bundle loads ui-primitives / store / slots.
368
+ if (/^@deepseek-ai\//.test(ctx.manifest.name || '')) return;
369
+
370
+ // One finding per surface, not per package: a plugin that lists six client
371
+ // packages is one decision to review, not six defects.
372
+ const loaded = new Map();
373
+ if (ctx.entries.client !== undefined && ctx.exists(ctx.entries.client)) {
374
+ const file = ctx.byPath(ctx.entries.client);
375
+ if (file) {
376
+ const re = /(?:from\s*|require\s*\(\s*|[A-Za-z_$][\w$]*\s*\(\s*)["'](@deepseek-ai\/dsh-client[a-z0-9-]*)["']/g;
377
+ let m;
378
+ while ((m = re.exec(file.masked)) !== null) {
379
+ if (loaded.has(m[1])) continue;
380
+ loaded.set(m[1], { line: file.strings.find((s) => s.start >= m.index && s.start - m.index < 80)?.line, evidence: m[0].trim() });
381
+ }
382
+ }
383
+ }
384
+ if (loaded.size > 0) {
385
+ const first = loaded.values().next().value;
386
+ push({
387
+ file: ctx.entries.client,
388
+ line: first.line,
389
+ message: 'loads ' + loaded.size + ' Harness Client package' + (loaded.size === 1 ? '' : 's') + ' from the browser module table: ' + [...loaded.keys()].join(', ') + '.',
390
+ evidence: first.evidence,
391
+ hint: 'The reference advises copying the primitive instead: these packages change without notice and a throwing component blanks the slot. Official first-party bundles do the same, so treat this as a coupling risk to watch at upgrade time, not as a defect.',
392
+ });
393
+ }
394
+
395
+ const coupled = [];
396
+ for (const depField of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
397
+ for (const name of Object.keys(ctx.manifest[depField] || {})) {
398
+ if (/^@deepseek-ai\/dsh-client/.test(name)) coupled.push(depField + ':' + name);
399
+ }
400
+ }
401
+ if (coupled.length > 0) {
402
+ push({
403
+ file: 'package.json',
404
+ line: 1,
405
+ message: 'declares ' + coupled.length + ' Harness Client package dependenc' + (coupled.length === 1 ? 'y' : 'ies') + ': ' + coupled.join(', ') + '.',
406
+ evidence: coupled.join(' '),
407
+ hint: 'dsh.client.inject entries only order activation and stay allowed; a dependency means the code loads it.',
408
+ });
409
+ }
410
+ },
411
+ },
412
+
413
+ {
414
+ id: 'W2',
415
+ level: 'warn',
416
+ title: 'folds session state by rescanning the log',
417
+ doc: DOC.practices,
418
+ check(ctx, options, push) {
419
+ for (const file of ctx.jsFiles) {
420
+ const registration = file.strings.find((s) => s.value === 'session/event');
421
+ if (registration === undefined) continue;
422
+ if (file.masked.includes('sessionProjections')) continue;
423
+ // Scope the check to the listener body: another object's `.events` in the
424
+ // same file is not a rescan. (Measured against official packages.)
425
+ const block = blockAfter(file.structural, registration.end);
426
+ if (block === undefined) continue;
427
+ const rescans = /\.events\b/.test(block.body) || /eventsBackwards\s*\(/.test(block.body);
428
+ if (!rescans) continue;
429
+ push({
430
+ file: file.path,
431
+ line: registration.line,
432
+ message: 'subscribes to session/event and reads the whole event log.',
433
+ evidence: "ctx.on('session/event', ...) with a full-log read",
434
+ hint: 'A ctx.sessionProjections unit folds events incrementally and returns the same reference for ignored events; per-event rescans cost the whole session on every append.',
435
+ });
436
+ }
437
+ },
438
+ },
439
+
440
+ {
441
+ id: 'W3',
442
+ level: 'info',
443
+ title: 'creates a timer with no visible owner (review hint)',
444
+ doc: DOC.practices,
445
+ check(ctx, options, push) {
446
+ for (const file of ctx.jsFiles) {
447
+ // Only timers at module top level are unambiguous leaks: a timer created
448
+ // inside a request handler may well be cleared by the surrounding code.
449
+ const re = /set(Interval|Timeout)\s*\(/g;
450
+ let m;
451
+ while ((m = re.exec(file.structural)) !== null) {
452
+ if (braceDepthAt(file.structural, m.index) !== 0) continue;
453
+ push({
454
+ file: file.path,
455
+ line: lineOfIndex(file.lineStarts, m.index),
456
+ message: 'appears to start a timer outside any function scope, with no context effect nearby.',
457
+ evidence: m[0],
458
+ hint: 'Registration on a context owns its cleanup: unload and hot reload dispose what the effect returned. Brace depth is approximate on bundled or minified output, so confirm this one by reading the code.',
459
+ });
460
+ break;
461
+ }
462
+ }
463
+ },
464
+ },
465
+
466
+ {
467
+ id: 'I1',
468
+ level: 'info',
469
+ title: 'declares no DSH compatibility range',
470
+ doc: DOC.host,
471
+ check(ctx, options, push) {
472
+ const declared = ctx.manifest.dsh && ctx.manifest.dsh.compatibility && ctx.manifest.dsh.compatibility.dshReleases;
473
+ if (declared && Object.keys(declared).length > 0) {
474
+ if (options.dshVersion && !(options.dshVersion in declared)) {
475
+ push({
476
+ file: 'package.json',
477
+ line: manifestLine(ctx.manifestText, 'dshReleases'),
478
+ message: 'does not list the running DSH version "' + options.dshVersion + '" in dsh.compatibility.dshReleases.',
479
+ evidence: 'declared: ' + Object.keys(declared).join(', '),
480
+ hint: 'An unlisted version is unknown, not incompatible: verify it before publishing a compatibility claim.',
481
+ });
482
+ }
483
+ return;
484
+ }
485
+ push({
486
+ file: 'package.json',
487
+ line: manifestLine(ctx.manifestText, '"dsh"'),
488
+ message: 'has no dsh.compatibility.dshReleases entry.',
489
+ evidence: 'dsh.compatibility absent',
490
+ hint: 'Most published plugins omit this today, which is why empirical verification matters more than self-declaration.',
491
+ });
492
+ },
493
+ },
494
+
495
+ {
496
+ id: 'I2',
497
+ level: 'info',
498
+ title: 'ships no display metadata',
499
+ doc: DOC.host,
500
+ check(ctx, options, push) {
501
+ if (ctx.entries.icon === undefined) {
502
+ push({
503
+ file: 'package.json',
504
+ line: 1,
505
+ message: 'declares no top-level "icon".',
506
+ evidence: 'icon absent',
507
+ hint: 'Management cards and the settings inventory read title, description and icon without activating the plugin.',
508
+ });
509
+ }
510
+ const dir = ctx.entries.localeDir;
511
+ if (dir === undefined) {
512
+ push({
513
+ file: 'package.json',
514
+ line: 1,
515
+ message: 'ships no locale/<lang>.json metadata.',
516
+ evidence: 'locale/ absent',
517
+ hint: 'Title and description live under the "meta" key of locale/en.json, not in package.json.',
518
+ });
519
+ return;
520
+ }
521
+ const candidates = ctx.localeFiles();
522
+ const withMeta = candidates.filter((c) => c.meta && (c.meta.title || c.meta.description));
523
+ if (withMeta.length === 0) {
524
+ push({
525
+ file: dir + '/',
526
+ line: 1,
527
+ message: 'locale files declare no "meta" title or description.',
528
+ evidence: 'locale files: ' + candidates.length,
529
+ hint: 'Expected shape: { "meta": { "title": "...", "description": "..." } }.',
530
+ });
531
+ }
532
+ },
533
+ },
534
+
535
+ {
536
+ id: 'I3',
537
+ level: 'warn',
538
+ title: 'locale metadata is not published',
539
+ doc: DOC.host,
540
+ check(ctx, options, push) {
541
+ const dir = ctx.entries.localeDir;
542
+ if (dir === undefined) return;
543
+ const exported = Object.keys(ctx.manifest.exports || {}).some((k) => k.includes('locale'));
544
+ if (!exported) {
545
+ push({
546
+ file: 'package.json',
547
+ line: manifestLine(ctx.manifestText, '"exports"'),
548
+ message: 'a locale/ directory exists but exports declares no locale subpath.',
549
+ evidence: 'exports: ' + JSON.stringify(Object.keys(ctx.manifest.exports || {})),
550
+ hint: 'Display metadata is read through the locale subpath.',
551
+ });
552
+ }
553
+ if (ctx.exists(dir + '/en.json') && ctx.packlist.complete && !hasPackedFile(ctx.packlist, dir + '/en.json')) {
554
+ push({
555
+ file: 'package.json',
556
+ line: manifestLine(ctx.manifestText, '"files"'),
557
+ message: 'locale files are excluded from the published package by "files" or .npmignore.',
558
+ evidence: 'files: ' + JSON.stringify(ctx.files),
559
+ hint: 'Metadata missing from the tarball shows as a fallback title and default artwork.',
560
+ });
561
+ }
562
+ },
563
+ },
564
+ ];
565
+
566
+ function manifestLine(text, needle) {
567
+ if (!text || !needle) return 1;
568
+ const idx = text.indexOf(needle.replace(/^"|"$/g, ''));
569
+ if (idx < 0) return 1;
570
+ return text.slice(0, idx).split('\n').length;
571
+ }
572
+
573
+ export function localeMeta(dir, localeDir) {
574
+ const out = [];
575
+ for (const lang of ['en', 'zh', 'zh-CN', 'ja', 'ru']) {
576
+ const p = join(dir, localeDir, lang + '.json');
577
+ if (!existsSync(p)) continue;
578
+ try { out.push({ lang, ...JSON.parse(readFileSync(p, 'utf8')) }); } catch { /* ignored: malformed locale file */ }
579
+ }
580
+ return out;
581
+ }