@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 0.1.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 +300 -2
- package/bin/dsh-plugin-lint.mjs +93 -0
- package/package.json +54 -4
- package/scripts/lib/probe.mjs +391 -0
- package/scripts/probe-boot.mjs +57 -0
- package/src/data/event-types.json +291 -0
- package/src/index.js +76 -0
- package/src/load.js +116 -0
- package/src/mask.js +156 -0
- package/src/report.js +79 -0
- package/src/rules.js +379 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const COLOR = {
|
|
2
|
+
error: '\u001b[31m',
|
|
3
|
+
warn: '\u001b[33m',
|
|
4
|
+
info: '\u001b[36m',
|
|
5
|
+
dim: '\u001b[2m',
|
|
6
|
+
bold: '\u001b[1m',
|
|
7
|
+
reset: '\u001b[0m',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const LABEL = { error: 'error', warn: 'warn ', info: 'info ' };
|
|
11
|
+
|
|
12
|
+
export function formatText(report, options = {}) {
|
|
13
|
+
const paint = (level, text) => (options.color ? COLOR[level] + text + COLOR.reset : text);
|
|
14
|
+
const dim = (text) => (options.color ? COLOR.dim + text + COLOR.reset : text);
|
|
15
|
+
const lines = [];
|
|
16
|
+
const t = report.target;
|
|
17
|
+
lines.push((options.color ? COLOR.bold : '') + t.name + '@' + t.version
|
|
18
|
+
+ (options.color ? COLOR.reset : '') + dim(' ' + report.checks + ' checks'));
|
|
19
|
+
lines.push('');
|
|
20
|
+
|
|
21
|
+
if (report.findings.length === 0) {
|
|
22
|
+
lines.push(' ' + paint('info', 'clean') + ' no findings');
|
|
23
|
+
}
|
|
24
|
+
for (const f of report.findings) {
|
|
25
|
+
const where = f.file ? f.file + (f.line ? ':' + f.line : '') : '';
|
|
26
|
+
lines.push(' ' + paint(f.level, f.rule + ' ' + LABEL[f.level]) + ' ' + f.title);
|
|
27
|
+
if (where) lines.push(' ' + dim(where));
|
|
28
|
+
if (f.message) lines.push(' ' + f.message);
|
|
29
|
+
if (f.evidence) lines.push(' ' + dim('evidence: ' + f.evidence));
|
|
30
|
+
if (f.hint) lines.push(' ' + dim('why: ' + f.hint));
|
|
31
|
+
lines.push(' ' + dim('doc: ' + f.doc));
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const s = report.summary;
|
|
36
|
+
lines.push(' ' + s.error + ' error · ' + s.warn + ' warn · ' + s.info + ' info');
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toSarif(reports) {
|
|
41
|
+
const list = Array.isArray(reports) ? reports : [reports];
|
|
42
|
+
const ruleIndex = new Map();
|
|
43
|
+
const rules = [];
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const report of list) {
|
|
46
|
+
for (const f of report.findings) {
|
|
47
|
+
if (!ruleIndex.has(f.rule)) {
|
|
48
|
+
ruleIndex.set(f.rule, rules.length);
|
|
49
|
+
rules.push({
|
|
50
|
+
id: f.rule,
|
|
51
|
+
name: f.rule,
|
|
52
|
+
shortDescription: { text: f.title },
|
|
53
|
+
helpUri: 'https://github.com/deepseek-ai/deepseek-harness',
|
|
54
|
+
properties: { doc: f.doc, level: f.level },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
results.push({
|
|
58
|
+
ruleId: f.rule,
|
|
59
|
+
ruleIndex: ruleIndex.get(f.rule),
|
|
60
|
+
level: f.level === 'error' ? 'error' : f.level === 'warn' ? 'warning' : 'note',
|
|
61
|
+
message: { text: f.message + (f.hint ? ' — ' + f.hint : '') },
|
|
62
|
+
locations: [{
|
|
63
|
+
physicalLocation: {
|
|
64
|
+
artifactLocation: { uri: report.target.path + '/' + (f.file || 'package.json') },
|
|
65
|
+
region: { startLine: f.line || 1 },
|
|
66
|
+
},
|
|
67
|
+
}],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
version: '2.1.0',
|
|
73
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
74
|
+
runs: [{
|
|
75
|
+
tool: { driver: { name: 'dsh-plugin-lint', informationUri: 'https://github.com/<owner>/dsh-plugin-lint', rules } },
|
|
76
|
+
results,
|
|
77
|
+
}],
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { appendCallSites, blockAfter, braceDepthAt, lineOfIndex, stringAfter } from './mask.js';
|
|
4
|
+
import { filesCover } from './load.js';
|
|
5
|
+
|
|
6
|
+
const CATALOG = JSON.parse(readFileSync(new URL('./data/event-types.json', import.meta.url), 'utf8'));
|
|
7
|
+
export const EVENT_TYPES = new Set(CATALOG.types);
|
|
8
|
+
|
|
9
|
+
/** Upstream documents that ship inside a DSH installation. */
|
|
10
|
+
export const DOC = {
|
|
11
|
+
practices: 'dsh plugin-development / references/practices.md',
|
|
12
|
+
host: 'dsh plugin-development / references/host-plugin.md',
|
|
13
|
+
ui: 'dsh plugin-development / references/ui-plugin.md',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const EVENT_TYPE_RE = /^[a-z][a-z0-9-]*\/[a-z0-9-]+$/;
|
|
17
|
+
const HARNESS_CLIENT_PKG = /@deepseek-ai\/dsh-client[a-z0-9-]*/g;
|
|
18
|
+
|
|
19
|
+
export const RULES = [
|
|
20
|
+
{
|
|
21
|
+
id: 'E1',
|
|
22
|
+
level: 'error',
|
|
23
|
+
title: 'appends a session event type that appears nowhere in the official packages',
|
|
24
|
+
doc: DOC.practices,
|
|
25
|
+
check(ctx, options, push) {
|
|
26
|
+
for (const file of ctx.jsFiles) {
|
|
27
|
+
for (const site of appendCallSites(file.masked, file.strings)) {
|
|
28
|
+
if (!EVENT_TYPE_RE.test(site.type)) continue;
|
|
29
|
+
if (EVENT_TYPES.has(site.type)) continue;
|
|
30
|
+
push({
|
|
31
|
+
file: file.path,
|
|
32
|
+
line: site.line,
|
|
33
|
+
message: 'session.append("' + site.type + '", ...): this type does not appear anywhere in the official Harness packages (' + EVENT_TYPES.size + ' types observed).',
|
|
34
|
+
evidence: '.append("' + site.type + '", ...)',
|
|
35
|
+
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.',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
id: 'E2',
|
|
44
|
+
level: 'error',
|
|
45
|
+
title: 'client bundle factory id matches nothing the loader can ask for',
|
|
46
|
+
doc: DOC.ui,
|
|
47
|
+
check(ctx, options, push) {
|
|
48
|
+
if (ctx.entries.client === undefined || !ctx.exists(ctx.entries.client)) return;
|
|
49
|
+
const file = ctx.byPath(ctx.entries.client);
|
|
50
|
+
if (!file) return;
|
|
51
|
+
|
|
52
|
+
// The loader keys the browser module table by an id and throws when a bundle
|
|
53
|
+
// registers a different one (dsh-client-modules: "bundle … loaded without
|
|
54
|
+
// registering \"id\""). What it asks for is either the package name or a row
|
|
55
|
+
// name from the plugin's own patch, so both are acceptable. Asserting only the
|
|
56
|
+
// package name produced a false accusation against @roarpeng/graphflow, whose
|
|
57
|
+
// patch mounts the row "@roarpeng/graphflow/dsh".
|
|
58
|
+
const acceptable = new Set();
|
|
59
|
+
if (ctx.manifest.name) acceptable.add(ctx.manifest.name);
|
|
60
|
+
const patchRows = [];
|
|
61
|
+
if (ctx.entries.patch !== undefined && ctx.exists(ctx.entries.patch)) {
|
|
62
|
+
const patch = ctx.text(ctx.entries.patch) || '';
|
|
63
|
+
for (const m of patch.matchAll(/^\s*name:\s*['"]?([^'"\s#]+)['"]?/gm)) {
|
|
64
|
+
acceptable.add(m[1]);
|
|
65
|
+
patchRows.push(m[1]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const re = /__ModuleLoader__\s*\.\s*load\s*\(\s*\{\s*id\s*:\s*/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = re.exec(file.masked)) !== null) {
|
|
72
|
+
const s = stringAfter(file.strings, m.index + m[0].length, 8);
|
|
73
|
+
if (!s || acceptable.has(s.value)) continue;
|
|
74
|
+
push({
|
|
75
|
+
file: file.path,
|
|
76
|
+
line: s.line,
|
|
77
|
+
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(', ') + ')' : '') + '.',
|
|
78
|
+
evidence: '__ModuleLoader__.load({ id: "' + s.value + '" })',
|
|
79
|
+
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.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
id: 'E3',
|
|
87
|
+
level: 'error',
|
|
88
|
+
title: 'manifest references a file that is not in the package',
|
|
89
|
+
doc: DOC.host,
|
|
90
|
+
check(ctx, options, push) {
|
|
91
|
+
const declared = [
|
|
92
|
+
['host entry', ctx.entries.hostDeclared ? ctx.entries.host : undefined, 'manifest.main or exports["."]'],
|
|
93
|
+
['client entry', ctx.entries.client, 'exports["./client"]'],
|
|
94
|
+
['bundle patch', ctx.entries.patch, 'dsh.bundle.patch'],
|
|
95
|
+
['icon', ctx.entries.icon, 'icon'],
|
|
96
|
+
];
|
|
97
|
+
for (const [label, rel, source] of declared) {
|
|
98
|
+
if (rel === undefined) continue;
|
|
99
|
+
if (ctx.exists(rel)) continue;
|
|
100
|
+
push({
|
|
101
|
+
file: 'package.json',
|
|
102
|
+
line: manifestLine(ctx.manifestText, source),
|
|
103
|
+
message: label + ' points at "' + rel + '", which does not exist.',
|
|
104
|
+
evidence: source + ' -> ' + rel,
|
|
105
|
+
hint: 'Management pages read these paths without activating the plugin; a missing file is reported as a broken package.',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
id: 'E4',
|
|
113
|
+
level: 'error',
|
|
114
|
+
title: 'declares a client half without a client entry',
|
|
115
|
+
doc: DOC.ui,
|
|
116
|
+
check(ctx, options, push) {
|
|
117
|
+
const hasClientSection = Boolean(ctx.manifest.dsh && ctx.manifest.dsh.client);
|
|
118
|
+
if (!hasClientSection) return;
|
|
119
|
+
if (ctx.entries.client !== undefined && ctx.exists(ctx.entries.client)) return;
|
|
120
|
+
push({
|
|
121
|
+
file: 'package.json',
|
|
122
|
+
line: manifestLine(ctx.manifestText, 'dsh.client'),
|
|
123
|
+
message: 'dsh.client is declared but exports["./client"] does not resolve to a file.',
|
|
124
|
+
evidence: 'dsh.client present, no ./client export',
|
|
125
|
+
hint: 'The platform resolves the browser artifact through the ./client export.',
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
id: 'E5',
|
|
132
|
+
level: 'error',
|
|
133
|
+
title: 'published files omit an artifact the manifest depends on',
|
|
134
|
+
doc: DOC.host,
|
|
135
|
+
check(ctx, options, push) {
|
|
136
|
+
if (ctx.files === undefined || ctx.files.length === 0) return;
|
|
137
|
+
const artifacts = [
|
|
138
|
+
['host entry', ctx.entries.host],
|
|
139
|
+
['client entry', ctx.entries.client],
|
|
140
|
+
['bundle patch', ctx.entries.patch],
|
|
141
|
+
['icon', ctx.entries.icon],
|
|
142
|
+
];
|
|
143
|
+
for (const [label, rel] of artifacts) {
|
|
144
|
+
if (rel === undefined || !ctx.exists(rel)) continue;
|
|
145
|
+
if (filesCover(ctx.files, rel)) continue;
|
|
146
|
+
push({
|
|
147
|
+
file: 'package.json',
|
|
148
|
+
line: manifestLine(ctx.manifestText, '"files"'),
|
|
149
|
+
message: label + ' "' + rel + '" is not covered by "files", so it is absent from the published tarball.',
|
|
150
|
+
evidence: 'files: ' + JSON.stringify(ctx.files),
|
|
151
|
+
hint: 'This class of bug passes local testing and only appears after publish.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
id: 'W1',
|
|
159
|
+
level: 'warn',
|
|
160
|
+
title: 'couples the plugin to a Harness Client package',
|
|
161
|
+
doc: DOC.ui,
|
|
162
|
+
check(ctx, options, push) {
|
|
163
|
+
// First-party packages ship in the same release as the browser module table,
|
|
164
|
+
// so the coupling this rule warns about does not apply to them. Measured on
|
|
165
|
+
// 2026-10-13: every official client bundle loads ui-primitives / store / slots.
|
|
166
|
+
if (/^@deepseek-ai\//.test(ctx.manifest.name || '')) return;
|
|
167
|
+
|
|
168
|
+
// One finding per surface, not per package: a plugin that lists six client
|
|
169
|
+
// packages is one decision to review, not six defects.
|
|
170
|
+
const loaded = new Map();
|
|
171
|
+
if (ctx.entries.client !== undefined && ctx.exists(ctx.entries.client)) {
|
|
172
|
+
const file = ctx.byPath(ctx.entries.client);
|
|
173
|
+
if (file) {
|
|
174
|
+
const re = /(?:from\s*|require\s*\(\s*|[A-Za-z_$][\w$]*\s*\(\s*)["'](@deepseek-ai\/dsh-client[a-z0-9-]*)["']/g;
|
|
175
|
+
let m;
|
|
176
|
+
while ((m = re.exec(file.masked)) !== null) {
|
|
177
|
+
if (loaded.has(m[1])) continue;
|
|
178
|
+
loaded.set(m[1], { line: file.strings.find((s) => s.start >= m.index && s.start - m.index < 80)?.line, evidence: m[0].trim() });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (loaded.size > 0) {
|
|
183
|
+
const first = loaded.values().next().value;
|
|
184
|
+
push({
|
|
185
|
+
file: ctx.entries.client,
|
|
186
|
+
line: first.line,
|
|
187
|
+
message: 'loads ' + loaded.size + ' Harness Client package' + (loaded.size === 1 ? '' : 's') + ' from the browser module table: ' + [...loaded.keys()].join(', ') + '.',
|
|
188
|
+
evidence: first.evidence,
|
|
189
|
+
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.',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const coupled = [];
|
|
194
|
+
for (const depField of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
195
|
+
for (const name of Object.keys(ctx.manifest[depField] || {})) {
|
|
196
|
+
if (/^@deepseek-ai\/dsh-client/.test(name)) coupled.push(depField + ':' + name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (coupled.length > 0) {
|
|
200
|
+
push({
|
|
201
|
+
file: 'package.json',
|
|
202
|
+
line: 1,
|
|
203
|
+
message: 'declares ' + coupled.length + ' Harness Client package dependenc' + (coupled.length === 1 ? 'y' : 'ies') + ': ' + coupled.join(', ') + '.',
|
|
204
|
+
evidence: coupled.join(' '),
|
|
205
|
+
hint: 'dsh.client.inject entries only order activation and stay allowed; a dependency means the code loads it.',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
id: 'W2',
|
|
213
|
+
level: 'warn',
|
|
214
|
+
title: 'folds session state by rescanning the log',
|
|
215
|
+
doc: DOC.practices,
|
|
216
|
+
check(ctx, options, push) {
|
|
217
|
+
for (const file of ctx.jsFiles) {
|
|
218
|
+
const registration = file.strings.find((s) => s.value === 'session/event');
|
|
219
|
+
if (registration === undefined) continue;
|
|
220
|
+
if (file.masked.includes('sessionProjections')) continue;
|
|
221
|
+
// Scope the check to the listener body: another object's `.events` in the
|
|
222
|
+
// same file is not a rescan. (Measured against official packages.)
|
|
223
|
+
const block = blockAfter(file.structural, registration.end);
|
|
224
|
+
if (block === undefined) continue;
|
|
225
|
+
const rescans = /\.events\b/.test(block.body) || /eventsBackwards\s*\(/.test(block.body);
|
|
226
|
+
if (!rescans) continue;
|
|
227
|
+
push({
|
|
228
|
+
file: file.path,
|
|
229
|
+
line: registration.line,
|
|
230
|
+
message: 'subscribes to session/event and reads the whole event log.',
|
|
231
|
+
evidence: "ctx.on('session/event', ...) with a full-log read",
|
|
232
|
+
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.',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
{
|
|
239
|
+
id: 'W3',
|
|
240
|
+
level: 'info',
|
|
241
|
+
title: 'creates a timer with no visible owner (review hint)',
|
|
242
|
+
doc: DOC.practices,
|
|
243
|
+
check(ctx, options, push) {
|
|
244
|
+
for (const file of ctx.jsFiles) {
|
|
245
|
+
// Only timers at module top level are unambiguous leaks: a timer created
|
|
246
|
+
// inside a request handler may well be cleared by the surrounding code.
|
|
247
|
+
const re = /set(Interval|Timeout)\s*\(/g;
|
|
248
|
+
let m;
|
|
249
|
+
while ((m = re.exec(file.structural)) !== null) {
|
|
250
|
+
if (braceDepthAt(file.structural, m.index) !== 0) continue;
|
|
251
|
+
push({
|
|
252
|
+
file: file.path,
|
|
253
|
+
line: lineOfIndex(file.lineStarts, m.index),
|
|
254
|
+
message: 'appears to start a timer outside any function scope, with no context effect nearby.',
|
|
255
|
+
evidence: m[0],
|
|
256
|
+
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.',
|
|
257
|
+
});
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
id: 'I1',
|
|
266
|
+
level: 'info',
|
|
267
|
+
title: 'declares no DSH compatibility range',
|
|
268
|
+
doc: DOC.host,
|
|
269
|
+
check(ctx, options, push) {
|
|
270
|
+
const declared = ctx.manifest.dsh && ctx.manifest.dsh.compatibility && ctx.manifest.dsh.compatibility.dshReleases;
|
|
271
|
+
if (declared && Object.keys(declared).length > 0) {
|
|
272
|
+
if (options.dshVersion && !(options.dshVersion in declared)) {
|
|
273
|
+
push({
|
|
274
|
+
file: 'package.json',
|
|
275
|
+
line: manifestLine(ctx.manifestText, 'dshReleases'),
|
|
276
|
+
message: 'does not list the running DSH version "' + options.dshVersion + '" in dsh.compatibility.dshReleases.',
|
|
277
|
+
evidence: 'declared: ' + Object.keys(declared).join(', '),
|
|
278
|
+
hint: 'An unlisted version is unknown, not incompatible: verify it before publishing a compatibility claim.',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
push({
|
|
284
|
+
file: 'package.json',
|
|
285
|
+
line: manifestLine(ctx.manifestText, '"dsh"'),
|
|
286
|
+
message: 'has no dsh.compatibility.dshReleases entry.',
|
|
287
|
+
evidence: 'dsh.compatibility absent',
|
|
288
|
+
hint: 'Most published plugins omit this today, which is why empirical verification matters more than self-declaration.',
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
id: 'I2',
|
|
295
|
+
level: 'info',
|
|
296
|
+
title: 'ships no display metadata',
|
|
297
|
+
doc: DOC.host,
|
|
298
|
+
check(ctx, options, push) {
|
|
299
|
+
if (ctx.entries.icon === undefined) {
|
|
300
|
+
push({
|
|
301
|
+
file: 'package.json',
|
|
302
|
+
line: 1,
|
|
303
|
+
message: 'declares no top-level "icon".',
|
|
304
|
+
evidence: 'icon absent',
|
|
305
|
+
hint: 'Management cards and the settings inventory read title, description and icon without activating the plugin.',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const dir = ctx.entries.localeDir;
|
|
309
|
+
if (dir === undefined) {
|
|
310
|
+
push({
|
|
311
|
+
file: 'package.json',
|
|
312
|
+
line: 1,
|
|
313
|
+
message: 'ships no locale/<lang>.json metadata.',
|
|
314
|
+
evidence: 'locale/ absent',
|
|
315
|
+
hint: 'Title and description live under the "meta" key of locale/en.json, not in package.json.',
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const candidates = ctx.localeFiles();
|
|
320
|
+
const withMeta = candidates.filter((c) => c.meta && (c.meta.title || c.meta.description));
|
|
321
|
+
if (withMeta.length === 0) {
|
|
322
|
+
push({
|
|
323
|
+
file: dir + '/',
|
|
324
|
+
line: 1,
|
|
325
|
+
message: 'locale files declare no "meta" title or description.',
|
|
326
|
+
evidence: 'locale files: ' + candidates.length,
|
|
327
|
+
hint: 'Expected shape: { "meta": { "title": "...", "description": "..." } }.',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
id: 'I3',
|
|
335
|
+
level: 'warn',
|
|
336
|
+
title: 'locale metadata is not published',
|
|
337
|
+
doc: DOC.host,
|
|
338
|
+
check(ctx, options, push) {
|
|
339
|
+
const dir = ctx.entries.localeDir;
|
|
340
|
+
if (dir === undefined) return;
|
|
341
|
+
const exported = Object.keys(ctx.manifest.exports || {}).some((k) => k.includes('locale'));
|
|
342
|
+
if (!exported) {
|
|
343
|
+
push({
|
|
344
|
+
file: 'package.json',
|
|
345
|
+
line: manifestLine(ctx.manifestText, '"exports"'),
|
|
346
|
+
message: 'a locale/ directory exists but exports declares no locale subpath.',
|
|
347
|
+
evidence: 'exports: ' + JSON.stringify(Object.keys(ctx.manifest.exports || {})),
|
|
348
|
+
hint: 'Display metadata is read through the locale subpath.',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (ctx.files && ctx.files.length > 0 && !filesCover(ctx.files, dir + '/en.json')) {
|
|
352
|
+
push({
|
|
353
|
+
file: 'package.json',
|
|
354
|
+
line: manifestLine(ctx.manifestText, '"files"'),
|
|
355
|
+
message: 'locale files are not covered by "files".',
|
|
356
|
+
evidence: 'files: ' + JSON.stringify(ctx.files),
|
|
357
|
+
hint: 'Metadata missing from the tarball shows as a fallback title and default artwork.',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
function manifestLine(text, needle) {
|
|
365
|
+
if (!text || !needle) return 1;
|
|
366
|
+
const idx = text.indexOf(needle.replace(/^"|"$/g, ''));
|
|
367
|
+
if (idx < 0) return 1;
|
|
368
|
+
return text.slice(0, idx).split('\n').length;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function localeMeta(dir, localeDir) {
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const lang of ['en', 'zh', 'zh-CN', 'ja', 'ru']) {
|
|
374
|
+
const p = join(dir, localeDir, lang + '.json');
|
|
375
|
+
if (!existsSync(p)) continue;
|
|
376
|
+
try { out.push({ lang, ...JSON.parse(readFileSync(p, 'utf8')) }); } catch { /* ignored: malformed locale file */ }
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|