dsh-scout 0.9.2 → 0.9.3
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/CHANGELOG.md +38 -1
- package/README.en.md +134 -45
- package/README.md +134 -45
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -1
- package/docs/dsh-scout-product-contract.md +20 -1
- package/package.json +1 -1
- package/scripts/check-release.mjs +228 -0
- package/tests/acceptance.test.mjs +62 -2
- package/tests/package.test.mjs +572 -3
package/tests/package.test.mjs
CHANGED
|
@@ -22,8 +22,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
22
22
|
* - `iconOf()` accepts a manifest-relative SVG/PNG/JPEG/WebP of at most
|
|
23
23
|
* 256 KiB that stays inside the package directory after realpath resolution.
|
|
24
24
|
*
|
|
25
|
-
* This suite reads package.json, locale/, and
|
|
26
|
-
* `dist/`, so it is independent of the build.
|
|
25
|
+
* This suite reads package.json, locale/, assets/, and the two READMEs only. It
|
|
26
|
+
* never imports `dist/`, so it is independent of the build.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const testsDir = dirname(fileURLToPath(import.meta.url))
|
|
@@ -39,7 +39,7 @@ const PACKAGE_NAME = 'dsh-scout'
|
|
|
39
39
|
* so it must be bumped by hand with every release. Do not read it back from the
|
|
40
40
|
* manifest - a tautological assertion would stop catching a forgotten bump.
|
|
41
41
|
*/
|
|
42
|
-
const RELEASE_VERSION = '0.9.
|
|
42
|
+
const RELEASE_VERSION = '0.9.3'
|
|
43
43
|
const DSH_RANGE = '>=0.1.0-rc.6 <0.3.0-0'
|
|
44
44
|
const NODE_RANGE = '>=22.19.0'
|
|
45
45
|
const MAX_ICON_BYTES = 256 * 1024
|
|
@@ -65,6 +65,575 @@ test(`publishes the ${RELEASE_VERSION} package identity`, () => {
|
|
|
65
65
|
assert.ok(manifest.description.trim().length > 0, 'description must not be blank')
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* README release-reference guard.
|
|
70
|
+
*
|
|
71
|
+
* Neither `scripts/check-release.mjs` nor `.github/workflows/ci.yml` reads the
|
|
72
|
+
* READMEs, so a forgotten version bump there used to be invisible - 0.9.3 nearly
|
|
73
|
+
* shipped with the install chapter still pinning 0.9.2. This guard runs on every
|
|
74
|
+
* `npm test` and in CI and costs milliseconds, which is why it lives here rather
|
|
75
|
+
* than in the four-version release gate (about 2 minutes).
|
|
76
|
+
*
|
|
77
|
+
* Only a *live* reference is guarded, i.e. a version that names the artifact
|
|
78
|
+
* this release produces or installs:
|
|
79
|
+
* - `dsh-scout-<semver>.tgz` - the file `pnpm pack` writes (any context);
|
|
80
|
+
* - `dsh-scout@<semver>` - a pin rather than a mention: the operand of an
|
|
81
|
+
* install command (`add`, `install`, `dlx`, `i`),
|
|
82
|
+
* a specifier standing alone on its line (the
|
|
83
|
+
* value typed into the desktop app's plugin
|
|
84
|
+
* field), or a quoted `"package@version"` key
|
|
85
|
+
* (`compatibility.json`). A range or comparison
|
|
86
|
+
* (`^0.9.2`, `~0.9.2`, `>=0.9.2`, `=0.9.2`) in one
|
|
87
|
+
* of those positions is a live reference the guard
|
|
88
|
+
* cannot evaluate, so it fails closed and asks for
|
|
89
|
+
* an exact version instead of waving it through;
|
|
90
|
+
* - `dsh-scout...#v<semver>` - a git install ref;
|
|
91
|
+
* - `· dsh-scout <semver>` - the `/scout status` banner, whose shape is
|
|
92
|
+
* ` · dsh-scout ${PLUGIN_VERSION}` in
|
|
93
|
+
* `src/index.ts`. The `·` separator is required:
|
|
94
|
+
* without it this would also match prose and
|
|
95
|
+
* report titles that merely put the package name
|
|
96
|
+
* next to a historical version;
|
|
97
|
+
* - `本次发布创建 \`v<semver>\`` / `this release creates \`v<semver>\`` - the tag
|
|
98
|
+
* this release creates; the phrase is what keeps
|
|
99
|
+
* the historical tag list on the same line out of
|
|
100
|
+
* the guard. 0.9.3 freezes the rule set here: a
|
|
101
|
+
* new version-reference style must be evaluated in
|
|
102
|
+
* the change that introduces it, not by widening
|
|
103
|
+
* this list indefinitely.
|
|
104
|
+
* Every other version mention is history and passes untouched: the roadmap's
|
|
105
|
+
* completed milestones (`- ✅ v0.9.2: real-host end-to-end gate ...`),
|
|
106
|
+
* "since 0.9.1"-style prose (`### Output contract (since 0.9.1)`), and the
|
|
107
|
+
* version matrix's own notes about earlier runs ("This table previously recorded
|
|
108
|
+
* the 0.9.0 and 0.9.1 candidate tarball results"). Bumping the package must never
|
|
109
|
+
* rewrite those, and this guard must never report them.
|
|
110
|
+
*
|
|
111
|
+
* KNOWN GAP - the verified-version matrix's *live* claims are NOT covered. That
|
|
112
|
+
* section records the run for the release being cut, so its claims must move with
|
|
113
|
+
* the version, yet each sits in a prose or table position the rule set above does
|
|
114
|
+
* not claim:
|
|
115
|
+
* - the release sentence naming the tested artifact
|
|
116
|
+
* ("**`dsh-scout@<version>` candidate tarball passed on four exact DSH
|
|
117
|
+
* versions**");
|
|
118
|
+
* - that table's version column header ("| ... | <version> (candidate
|
|
119
|
+
* tarball) |");
|
|
120
|
+
* - the closing sentence that folds the run into this release ("本版改动 ...
|
|
121
|
+
* 在同样四个精确版本上复跑通过(4/4),故更新为本版本" / "so it is updated
|
|
122
|
+
* to this version").
|
|
123
|
+
* The consequence is concrete: a forgotten bump there passes green, and the next
|
|
124
|
+
* release ships a stale version matrix while every gate reports success. This is
|
|
125
|
+
* a recorded residual category, not an oversight - the guard is a maintainer aid,
|
|
126
|
+
* not a safety boundary. The matrix must therefore be re-checked with an
|
|
127
|
+
* independent extractor before every release (docs/verification-0.9.3.md lists
|
|
128
|
+
* that recheck as a release condition). Do not read the paragraph above as "the
|
|
129
|
+
* matrix is handled", and do not close this gap by widening the list above on a
|
|
130
|
+
* whim: evaluate that reference style in the change that introduces the
|
|
131
|
+
* declaration, the way every other style here was added.
|
|
132
|
+
*/
|
|
133
|
+
const README_FILES = ['README.md', 'README.en.md']
|
|
134
|
+
/** A version-shaped token; prerelease/build suffixes are admitted like SemVer. */
|
|
135
|
+
const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
|
|
136
|
+
/** `... add dsh-scout@1.2.3` - the specifier is the operand of an install verb. */
|
|
137
|
+
const INSTALL_OPERAND = /(?:^|[\s`'"(])(?:add|install|dlx|i)\s+(?:--?[\w-]+(?:=\S+)?\s+)*(?:file:)?$/
|
|
138
|
+
const TRAILING_QUOTES = /[`"')\]]+$/
|
|
139
|
+
|
|
140
|
+
/** `dsh-scout@1.2.3` alone on its line (optionally a list item or in backticks). */
|
|
141
|
+
function isStandaloneSpecifier(line, index, text) {
|
|
142
|
+
const before = line.slice(0, index).replace(/^[\s>]*(?:[-*+]\s+)?/, '')
|
|
143
|
+
const after = line.slice(index + text.length).replace(TRAILING_QUOTES, '').trim()
|
|
144
|
+
return before === '' && after === ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `"dsh-scout@1.2.3": [...]` - a JSON object key naming one pinned package version. */
|
|
148
|
+
function isQuotedPinKey(line, index, text) {
|
|
149
|
+
const before = line.slice(0, index)
|
|
150
|
+
const after = line.slice(index + text.length)
|
|
151
|
+
return /["']$/.test(before) && /^["']\s*:/.test(after)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An exact specifier: `1.2.3` or `v1.2.3`. */
|
|
155
|
+
const EXACT_SPECIFIER = new RegExp(`^v?(${SEMVER_SOURCE})$`)
|
|
156
|
+
/**
|
|
157
|
+
* A specifier that names a version *or a range* (`0.9`, `0.9.x`, `^0.9.2`,
|
|
158
|
+
* `>=0.9.2`, `=0.9.2`, `*`, `1.x`). These are live references - node-semver
|
|
159
|
+
* satisfies them against a released version - but this guard carries no range
|
|
160
|
+
* semantics, so waving them through would break its stated contract (F-1). It
|
|
161
|
+
* fails closed instead. Dist-tags (`latest`, `next`) and placeholders
|
|
162
|
+
* (`<version>`) name no version and stay legitimate.
|
|
163
|
+
*/
|
|
164
|
+
const VERSION_OR_RANGE_SPECIFIER = /^[\^~<>=|\s]*(?:v?\d|\*|x)/i
|
|
165
|
+
|
|
166
|
+
/** Classify one `dsh-scout@<token>` specifier: exact version, unsupported, or not a version claim. */
|
|
167
|
+
function classifySpecifier(token) {
|
|
168
|
+
const exact = EXACT_SPECIFIER.exec(token)
|
|
169
|
+
if (exact !== null) return { version: exact[1] }
|
|
170
|
+
if (VERSION_OR_RANGE_SPECIFIER.test(token)) {
|
|
171
|
+
return {
|
|
172
|
+
problem:
|
|
173
|
+
`uses the unsupported specifier "${token}"; a range or comparison cannot be compared to ` +
|
|
174
|
+
'the released version - write the exact version, or extend RELEASE_REFERENCE_PATTERNS ' +
|
|
175
|
+
'with the semantics of this form',
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const RELEASE_REFERENCE_PATTERNS = [
|
|
182
|
+
{
|
|
183
|
+
kind: 'tarball name',
|
|
184
|
+
source: `dsh-scout-(${SEMVER_SOURCE})\\.tgz`,
|
|
185
|
+
classify: (match) => ({ version: match[1] }),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
kind: 'npm install pin',
|
|
189
|
+
// The token charset deliberately excludes sentence punctuation (CJK full
|
|
190
|
+
// stop, backticks, quotes), so `dsh-scout@0.9.3。` still reads as an exact pin
|
|
191
|
+
// while `dsh-scout@^0.9.2` is captured whole and classified.
|
|
192
|
+
source: 'dsh-scout@([0-9A-Za-z.*^~<>=|+-]+)',
|
|
193
|
+
classify: (match, line) => {
|
|
194
|
+
const livePosition =
|
|
195
|
+
INSTALL_OPERAND.test(line.slice(0, match.index).replace(TRAILING_QUOTES, '')) ||
|
|
196
|
+
isStandaloneSpecifier(line, match.index, match[0]) ||
|
|
197
|
+
isQuotedPinKey(line, match.index, match[0])
|
|
198
|
+
return livePosition ? classifySpecifier(match[1]) : null
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
kind: 'git install ref',
|
|
203
|
+
source: `dsh-scout(?:\\.git)?#v?(${SEMVER_SOURCE})`,
|
|
204
|
+
classify: (match) => ({ version: match[1] }),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
kind: 'status banner',
|
|
208
|
+
// ` · dsh-scout 1.2.3` - appended to `/scout status` by src/index.ts
|
|
209
|
+
// (` · dsh-scout ${PLUGIN_VERSION}`) and pinned at the runtime boundary by
|
|
210
|
+
// tests/acceptance.test.mjs. The leading separator is part of the contract:
|
|
211
|
+
// it is what keeps the banner from colliding with prose that merely names
|
|
212
|
+
// the package next to a version.
|
|
213
|
+
source: `·\\s*dsh-scout (${SEMVER_SOURCE})`,
|
|
214
|
+
classify: (match) => ({ version: match[1] }),
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
kind: 'release tag claim',
|
|
218
|
+
// `本次发布创建 \`v1.2.3\`` / `this release creates \`v1.2.3\`` - the tag this
|
|
219
|
+
// release creates. The surrounding phrase is what makes a bare `v<semver>`
|
|
220
|
+
// safe to guard: the same line also lists historical tags
|
|
221
|
+
// (`v0.3.0` ... `v0.9.2`), which must stay untouched, so a document-wide bare
|
|
222
|
+
// `v<semver>` rule would be a false-positive machine.
|
|
223
|
+
source: `(?:本次发布创建|this release creates)\\s*\`?v?(${SEMVER_SOURCE})\`?`,
|
|
224
|
+
classify: (match) => ({ version: match[1] }),
|
|
225
|
+
},
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
/** Collect every live install/artifact reference in one markdown document. */
|
|
229
|
+
function collectReleaseReferences(text, file) {
|
|
230
|
+
const found = []
|
|
231
|
+
text.split('\n').forEach((line, lineIndex) => {
|
|
232
|
+
for (const { kind, source, classify } of RELEASE_REFERENCE_PATTERNS) {
|
|
233
|
+
const pattern = new RegExp(source, 'g')
|
|
234
|
+
let match
|
|
235
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
236
|
+
const verdict = classify(match, line)
|
|
237
|
+
if (verdict === null) continue
|
|
238
|
+
found.push({
|
|
239
|
+
file,
|
|
240
|
+
line: lineIndex + 1,
|
|
241
|
+
column: match.index + 1,
|
|
242
|
+
kind,
|
|
243
|
+
text: match[0],
|
|
244
|
+
version: verdict.version ?? null,
|
|
245
|
+
problem: verdict.problem ?? null,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
return found
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const readmeReferences = () =>
|
|
254
|
+
README_FILES.flatMap((file) =>
|
|
255
|
+
collectReleaseReferences(readFileSync(join(packageRoot, file), 'utf8'), file),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Every guard failure in one document: pattern failures first, then the
|
|
260
|
+
* success-criteria section tokens. Only lines the pattern scan already reported
|
|
261
|
+
* as *offending* are skipped by the section scan (F-3) - a line that also carries
|
|
262
|
+
* a correct reference must still have its stale bare token reported. This
|
|
263
|
+
* function is the single source of the verdict, so the millisecond test and the
|
|
264
|
+
* preflight in scripts/check-release.mjs answer the same question the same way.
|
|
265
|
+
*/
|
|
266
|
+
function guardFailures(file, text) {
|
|
267
|
+
const failures = []
|
|
268
|
+
const reportedLines = new Set()
|
|
269
|
+
for (const reference of collectReleaseReferences(text, file)) {
|
|
270
|
+
if (reference.problem !== null) {
|
|
271
|
+
failures.push(
|
|
272
|
+
`${reference.file}:${reference.line} ${reference.text} ${reference.problem} (${reference.kind})`,
|
|
273
|
+
)
|
|
274
|
+
reportedLines.add(`${reference.file}:${reference.line}`)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (reference.version !== manifest.version) {
|
|
278
|
+
failures.push(
|
|
279
|
+
`${reference.file}:${reference.line} pins ${reference.text} but package.json is ` +
|
|
280
|
+
`${manifest.version} (${reference.kind})`,
|
|
281
|
+
)
|
|
282
|
+
reportedLines.add(`${reference.file}:${reference.line}`)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const anchor = SUCCESS_CRITERIA_SECTIONS.find((entry) => entry.file === file)
|
|
286
|
+
if (anchor === undefined) return failures
|
|
287
|
+
const section = locateSection(text, anchor.heading)
|
|
288
|
+
if (section === null) {
|
|
289
|
+
failures.push(`${file}: no heading matches "${anchor.label}" - ${SECTION_ANCHOR_REMEDY}`)
|
|
290
|
+
return failures
|
|
291
|
+
}
|
|
292
|
+
for (const token of collectSectionVersionTokens(text, file, section)) {
|
|
293
|
+
if (reportedLines.has(`${token.file}:${token.line}`)) continue
|
|
294
|
+
if (token.version === manifest.version) continue
|
|
295
|
+
failures.push(
|
|
296
|
+
`${token.file}:${token.line} pins ${token.text} in the success-criteria section but ` +
|
|
297
|
+
`package.json is ${manifest.version}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
return failures
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
test(`pins every README install, artifact, and success-criteria reference to ${RELEASE_VERSION}`, () => {
|
|
304
|
+
const offenders = README_FILES.flatMap((file) =>
|
|
305
|
+
guardFailures(file, readFileSync(join(packageRoot, file), 'utf8')),
|
|
306
|
+
)
|
|
307
|
+
assert.deepEqual(
|
|
308
|
+
offenders,
|
|
309
|
+
[],
|
|
310
|
+
[
|
|
311
|
+
`README version references must track package.json (${manifest.version}):`,
|
|
312
|
+
...offenders.map((line) => ` ${line}`),
|
|
313
|
+
'Historical mentions (verified-version matrix, roadmap, CHANGELOG, "since 0.9.x" prose) are deliberately not guarded.',
|
|
314
|
+
'Ranges and comparisons fail closed on purpose: the guard cannot decide whether they equal the released version.',
|
|
315
|
+
].join('\n'),
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('classifies live install references and historical version mentions apart', () => {
|
|
320
|
+
// Direction 1: every form that pins the artifact being released (8 exact forms,
|
|
321
|
+
// plus two range forms that must fail closed instead of passing silently).
|
|
322
|
+
const live = [
|
|
323
|
+
'pnpm pack # produces dsh-scout-0.9.2.tgz',
|
|
324
|
+
'dsh plugin --profile scout-demo add "file:$PWD/dsh-scout-0.9.2.tgz"',
|
|
325
|
+
'dsh plugin --profile <name> add dsh-scout@0.9.2',
|
|
326
|
+
'dsh plugin --profile scout-demo add github:MaxHou-infinity/dsh-scout#v0.9.2',
|
|
327
|
+
'dsh-scout@0.9.2',
|
|
328
|
+
' "dsh-scout@0.9.2": ["0.2.0-rc.2"]',
|
|
329
|
+
'[ver] VERIFY · identity needs_verification · sources 0 / claims 0 (verified 0, open 0) · dsh-scout 0.9.2',
|
|
330
|
+
'本次发布创建 `v0.9.2`。',
|
|
331
|
+
'and this release creates `v0.9.2`.',
|
|
332
|
+
'dsh plugin --profile p add dsh-scout@^0.9.2',
|
|
333
|
+
'dsh-scout@>=0.9.2',
|
|
334
|
+
].join('\n')
|
|
335
|
+
const liveRefs = collectReleaseReferences(live, 'README.fixture.md')
|
|
336
|
+
assert.deepEqual(
|
|
337
|
+
liveRefs.map(
|
|
338
|
+
(reference) => `${reference.line}:${reference.kind}:${reference.problem === null ? reference.version : 'UNSUPPORTED'}`,
|
|
339
|
+
),
|
|
340
|
+
[
|
|
341
|
+
'1:tarball name:0.9.2',
|
|
342
|
+
'2:tarball name:0.9.2',
|
|
343
|
+
'3:npm install pin:0.9.2',
|
|
344
|
+
'4:git install ref:0.9.2',
|
|
345
|
+
'5:npm install pin:0.9.2',
|
|
346
|
+
'6:npm install pin:0.9.2',
|
|
347
|
+
'7:status banner:0.9.2',
|
|
348
|
+
'8:release tag claim:0.9.2',
|
|
349
|
+
'9:release tag claim:0.9.2',
|
|
350
|
+
'10:npm install pin:UNSUPPORTED',
|
|
351
|
+
'11:npm install pin:UNSUPPORTED',
|
|
352
|
+
],
|
|
353
|
+
'a stale install/artifact reference must be extracted (with its line) so the guard can flag it',
|
|
354
|
+
)
|
|
355
|
+
assert.match(
|
|
356
|
+
liveRefs[9].problem,
|
|
357
|
+
/unsupported specifier "\^0\.9\.2"/,
|
|
358
|
+
'a range in install position must fail closed, never pass as a mention',
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
// Direction 2: history must pass - it is not a claim about the current release.
|
|
362
|
+
const historical = [
|
|
363
|
+
'- **Coverage (new in 0.9.1)**: per-dimension verified / open / untouched counts',
|
|
364
|
+
// A matrix-shaped release statement in a mention position. The guard does not
|
|
365
|
+
// claim this shape - that is the KNOWN GAP documented at the top of this
|
|
366
|
+
// guard, not a statement that the matrix is history. The real matrix's live
|
|
367
|
+
// claims must be re-checked with an independent extractor before a release.
|
|
368
|
+
'**`dsh-scout@0.9.2` passed on four exact DSH versions** (before the 2026-10-13 release)',
|
|
369
|
+
'| DSH version | npm channel (2026-10-12) | 0.9.2 (released) |',
|
|
370
|
+
'This table previously recorded the **0.9.0** and **0.9.1** candidate tarball results',
|
|
371
|
+
'- ✅ v0.9.2: real-host end-to-end gate (closing the U11 blind spot)',
|
|
372
|
+
'A 0.9.0 export has no completion marker and is never auto-rehydrated',
|
|
373
|
+
'- **Explicitly not in 0.9.1 or 0.9.2**: the client panel, custom session events',
|
|
374
|
+
'See the measured results of `dsh-scout@0.9.2` in the table above.',
|
|
375
|
+
// Near-misses for the status banner: the package name next to a version
|
|
376
|
+
// without the `·` separator is a mention, not the banner.
|
|
377
|
+
'dsh-scout profile smoke: PASS (dsh-scout 0.9.2, dsh 0.2.0-rc.2)',
|
|
378
|
+
'# dsh-scout 0.9.1 conformance audit report',
|
|
379
|
+
'Banner format note: the status line ends with dsh-scout 0.9.2 during 0.9.2 verification.',
|
|
380
|
+
// Near-misses for the release-tag claim: the phrase is what pins a bare
|
|
381
|
+
// `v<semver>` to the current release, so other release phrasings and the
|
|
382
|
+
// historical tag list on the same line must not be reported.
|
|
383
|
+
'上一次发布创建 `v0.9.2` 的 tag,本次只做迭代',
|
|
384
|
+
'the previous release created `v0.9.2`',
|
|
385
|
+
'仓库自 `v0.3.0` 起共 10 个 tag(`v0.3.0` … `v0.9.2`;`v0.9.1` 是本地迭代版)',
|
|
386
|
+
// Dist-tags, placeholders and a range quoted in prose name no installed
|
|
387
|
+
// version, so they are mentions rather than references.
|
|
388
|
+
'dsh plugin --profile p add dsh-scout@latest',
|
|
389
|
+
'dsh-scout@<version>',
|
|
390
|
+
'支持范围写法 `dsh-scout@^0.9.2` 吗?见上文说明。',
|
|
391
|
+
].join('\n')
|
|
392
|
+
assert.deepEqual(
|
|
393
|
+
collectReleaseReferences(historical, 'README.fixture.md'),
|
|
394
|
+
[],
|
|
395
|
+
'historical version mentions must never be reported: the fix is not to rewrite history',
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('fails closed on ranges and reports a section token once per line', () => {
|
|
400
|
+
// F-1: a range or comparison in a live position is a version claim this guard
|
|
401
|
+
// cannot evaluate; it must be reported, never waved through as a mention.
|
|
402
|
+
const ranged = [
|
|
403
|
+
'```sh',
|
|
404
|
+
'dsh-scout@^0.9.2',
|
|
405
|
+
'dsh plugin --profile p add dsh-scout@~0.9.2',
|
|
406
|
+
'dsh plugin --profile p add dsh-scout@=0.9.2',
|
|
407
|
+
'```',
|
|
408
|
+
].join('\n')
|
|
409
|
+
const rangedFailures = guardFailures('README.fixture.md', ranged)
|
|
410
|
+
assert.equal(rangedFailures.length, 3, 'every range/comparison specifier in a live position must fail closed')
|
|
411
|
+
assert.match(rangedFailures[0], /unsupported specifier "\^0\.9\.2"/)
|
|
412
|
+
|
|
413
|
+
// F-2: a prose `dsh-scout@<version>` naming the installed version is a section
|
|
414
|
+
// token even though it is not an install operand; the same text outside the
|
|
415
|
+
// section stays history.
|
|
416
|
+
const prose = [
|
|
417
|
+
'### 装上了吗(成功判据)',
|
|
418
|
+
'',
|
|
419
|
+
'安装 `dsh-scout@0.9.2` 后打开详情页。',
|
|
420
|
+
'',
|
|
421
|
+
'### 下一节',
|
|
422
|
+
'`dsh-scout@0.9.2` 在第 6 行的历史段落里。',
|
|
423
|
+
].join('\n')
|
|
424
|
+
assert.deepEqual(
|
|
425
|
+
guardFailures('README.md', prose),
|
|
426
|
+
['README.md:3 pins 0.9.2 in the success-criteria section but package.json is 0.9.3'],
|
|
427
|
+
'the section scan must see the `@` token, and the same text outside the section must stay history',
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
// F-3: a *correct* live pin on the line must not hide a stale bare token ...
|
|
431
|
+
const correctPinPlusStaleBare = [
|
|
432
|
+
'### 装上了吗(成功判据)',
|
|
433
|
+
'',
|
|
434
|
+
'安装:`i dsh-scout@0.9.3`,标题旁带 `v0.9.2`。',
|
|
435
|
+
].join('\n')
|
|
436
|
+
assert.deepEqual(
|
|
437
|
+
guardFailures('README.md', correctPinPlusStaleBare),
|
|
438
|
+
['README.md:3 pins v0.9.2 in the success-criteria section but package.json is 0.9.3'],
|
|
439
|
+
'a line is only skipped by the section scan when the pattern scan already reported it as offending',
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
// ... while a stale live pin is reported once, by the pattern scan, and the
|
|
443
|
+
// section scan does not repeat the same line (the dedupe both implementations share).
|
|
444
|
+
const stalePinPlusStaleBare = correctPinPlusStaleBare.replace('`i dsh-scout@0.9.3`', '`i dsh-scout@0.9.2`')
|
|
445
|
+
assert.deepEqual(
|
|
446
|
+
guardFailures('README.md', stalePinPlusStaleBare),
|
|
447
|
+
['README.md:3 pins dsh-scout@0.9.2 but package.json is 0.9.3 (npm install pin)'],
|
|
448
|
+
'a line the pattern scan already reported is not reported again by the section scan',
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('reads a live install reference out of each README', () => {
|
|
453
|
+
// Anti-vacuity, part 2: the extraction above must actually match the real
|
|
454
|
+
// READMEs. If both files stop carrying any versioned install/artifact
|
|
455
|
+
// reference, the guard silently becomes a no-op - that is a documentation
|
|
456
|
+
// regression worth failing on, not a reason to delete the guard.
|
|
457
|
+
const references = readmeReferences()
|
|
458
|
+
for (const file of README_FILES) {
|
|
459
|
+
const hits = references.filter((reference) => reference.file === file)
|
|
460
|
+
assert.ok(
|
|
461
|
+
hits.length > 0,
|
|
462
|
+
`${file} has no versioned install/artifact reference left. Either the install docs lost ` +
|
|
463
|
+
'their version pin (the regression this guard exists to catch) or the documentation now ' +
|
|
464
|
+
'uses a form this guard cannot read - extend RELEASE_REFERENCE_PATTERNS in the same ' +
|
|
465
|
+
'change instead of deleting this assertion.',
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
// The release-tag claim is phrase-anchored, so rewording the sentence would
|
|
469
|
+
// silently stop guarding it. Asserted per file: a rule that can no longer match
|
|
470
|
+
// anything is the false green this suite exists to prevent.
|
|
471
|
+
for (const file of README_FILES) {
|
|
472
|
+
assert.ok(
|
|
473
|
+
references.some((reference) => reference.file === file && reference.kind === 'release tag claim'),
|
|
474
|
+
`${file}: no "release tag claim" reference matched. Either the sentence that creates the ` +
|
|
475
|
+
'release tag was reworded (update the phrase in RELEASE_REFERENCE_PATTERNS, and in ' +
|
|
476
|
+
'scripts/check-release.mjs) or it was removed - in that case retire this rule ' +
|
|
477
|
+
'deliberately with the docs owner instead of leaving a guard that cannot fail.',
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Section-scoped guard: the success-criteria section.
|
|
484
|
+
*
|
|
485
|
+
* "Did it install?" tells a user how to confirm that *this* release is the one
|
|
486
|
+
* that loaded, so every version token inside it must be the released version -
|
|
487
|
+
* including bare forms the pattern guard above cannot safely claim: the card tag
|
|
488
|
+
* `` `v0.9.3` `` and `and version `0.9.3`` are not safe document-wide patterns,
|
|
489
|
+
* because a bare `v<semver>` elsewhere is legitimate history (the release-history
|
|
490
|
+
* line lists `v0.3.0` ... `v0.9.2`).
|
|
491
|
+
*
|
|
492
|
+
* The heading anchors are explicit constants, and a heading that no longer matches
|
|
493
|
+
* is a FAILURE, never a skip: silently skipping would let a reworded heading
|
|
494
|
+
* disarm the guard forever, which is the false green this suite exists to prevent.
|
|
495
|
+
* When the heading is legitimately reworded, update SUCCESS_CRITERIA_SECTIONS here
|
|
496
|
+
* and SECTION_ANCHORS in scripts/check-release.mjs in the same change.
|
|
497
|
+
*/
|
|
498
|
+
const SUCCESS_CRITERIA_SECTIONS = [
|
|
499
|
+
{ file: 'README.md', heading: /^(#{2,4})\s*装上了吗(成功判据)$/, label: '装上了吗(成功判据)' },
|
|
500
|
+
{
|
|
501
|
+
file: 'README.en.md',
|
|
502
|
+
heading: /^(#{2,4})\s*Did it install\? \(success criteria\)$/,
|
|
503
|
+
label: 'Did it install? (success criteria)',
|
|
504
|
+
},
|
|
505
|
+
]
|
|
506
|
+
/**
|
|
507
|
+
* A bare version token (`0.9.3`, `v0.9.3`, `0.9.3-rc.1`) that is not part of a
|
|
508
|
+
* larger identifier. `@` is deliberately *not* excluded: inside this section a
|
|
509
|
+
* prose or table reference such as `` 安装 `dsh-scout@0.9.2` 后 `` names a version
|
|
510
|
+
* that the pattern scan does not claim (it is not in an install position) and the
|
|
511
|
+
* section contract still covers it (F-2). `-` stays excluded so a tarball name
|
|
512
|
+
* (`dsh-scout-0.9.3.tgz`) is left to the pattern scan.
|
|
513
|
+
*/
|
|
514
|
+
const SECTION_VERSION_TOKEN = /(?<![\w./-])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?![\w.-])/g
|
|
515
|
+
const SECTION_ANCHOR_REMEDY =
|
|
516
|
+
'the success-criteria heading was reworded or moved: update SUCCESS_CRITERIA_SECTIONS in ' +
|
|
517
|
+
'tests/package.test.mjs (and SECTION_ANCHORS in scripts/check-release.mjs) to match. This ' +
|
|
518
|
+
'guard must never be skipped silently - a renamed heading would otherwise disarm it.'
|
|
519
|
+
|
|
520
|
+
/** Locate a heading-delimited section body; null when the anchor no longer matches. */
|
|
521
|
+
function locateSection(text, heading) {
|
|
522
|
+
const lines = text.split('\n')
|
|
523
|
+
let fenced = false
|
|
524
|
+
let start = -1
|
|
525
|
+
let level = 0
|
|
526
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
527
|
+
const trimmed = lines[index].trim()
|
|
528
|
+
// Fenced code blocks belong to the section body, so a `# ...` shell comment
|
|
529
|
+
// inside one is not a heading (the section's CLI example starts with one).
|
|
530
|
+
if (/^(?:```|~~~)/.test(trimmed)) {
|
|
531
|
+
fenced = !fenced
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
if (fenced) continue
|
|
535
|
+
const marker = /^(#{1,6})\s/.exec(trimmed)
|
|
536
|
+
if (start === -1) {
|
|
537
|
+
if (heading.test(trimmed)) {
|
|
538
|
+
start = index
|
|
539
|
+
level = /^#+/.exec(trimmed)[0].length
|
|
540
|
+
}
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
if (marker !== null && marker[1].length <= level) return { start, end: index }
|
|
544
|
+
}
|
|
545
|
+
return start === -1 ? null : { start, end: lines.length }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Every bare version token between the section heading and the next same-or-higher heading. */
|
|
549
|
+
function collectSectionVersionTokens(text, file, section) {
|
|
550
|
+
const lines = text.split('\n')
|
|
551
|
+
const found = []
|
|
552
|
+
for (let index = section.start; index < section.end; index += 1) {
|
|
553
|
+
const pattern = new RegExp(SECTION_VERSION_TOKEN.source, 'g')
|
|
554
|
+
let match
|
|
555
|
+
while ((match = pattern.exec(lines[index])) !== null) {
|
|
556
|
+
found.push({
|
|
557
|
+
file,
|
|
558
|
+
line: index + 1,
|
|
559
|
+
column: match.index + 1,
|
|
560
|
+
text: match[0],
|
|
561
|
+
version: match[1],
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return found
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
test('finds the success-criteria section in each README', () => {
|
|
569
|
+
const missing = []
|
|
570
|
+
for (const { file, heading, label } of SUCCESS_CRITERIA_SECTIONS) {
|
|
571
|
+
const text = readFileSync(join(packageRoot, file), 'utf8')
|
|
572
|
+
if (locateSection(text, heading) === null) missing.push(`${file}: no heading matches "${label}"`)
|
|
573
|
+
}
|
|
574
|
+
assert.deepEqual(
|
|
575
|
+
missing,
|
|
576
|
+
[],
|
|
577
|
+
`the success-criteria anchor must resolve: ${SECTION_ANCHOR_REMEDY}`,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
// The locator must end the section at the next real heading: a fenced `# ...`
|
|
581
|
+
// shell comment (the section's own CLI example) is body text, not a heading,
|
|
582
|
+
// and a `###` line after the fence must still close the section. A prose
|
|
583
|
+
// `` `dsh-scout@0.9.8` `` is a section token too (F-2: the pattern scan only
|
|
584
|
+
// claims install positions, so the section contract is what covers prose).
|
|
585
|
+
const synthetic = [
|
|
586
|
+
'### Did it install? (success criteria)',
|
|
587
|
+
'',
|
|
588
|
+
'```sh',
|
|
589
|
+
'# a shell comment, not a heading',
|
|
590
|
+
'dsh --profile p --dump-config',
|
|
591
|
+
'```',
|
|
592
|
+
'',
|
|
593
|
+
'still inside the section: v0.9.9',
|
|
594
|
+
'安装 `dsh-scout@0.9.8` 后打开详情页',
|
|
595
|
+
'',
|
|
596
|
+
'### Next section',
|
|
597
|
+
'v0.8.0 is history and must stay outside',
|
|
598
|
+
].join('\n')
|
|
599
|
+
const section = locateSection(synthetic, SUCCESS_CRITERIA_SECTIONS[1].heading)
|
|
600
|
+
assert.ok(section !== null, 'the synthetic anchor must resolve')
|
|
601
|
+
assert.deepEqual(
|
|
602
|
+
collectSectionVersionTokens(synthetic, 'README.fixture.md', section).map((token) => token.text),
|
|
603
|
+
['v0.9.9', '0.9.8'],
|
|
604
|
+
'a fenced `#` comment must not end the section, the next `###` heading must, and an `@`-prefixed prose token must be visible',
|
|
605
|
+
)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('keeps the success-criteria section scan non-vacuous', () => {
|
|
609
|
+
// The offence itself is asserted by guardFailures in the test above. This test
|
|
610
|
+
// only proves the section scan still has a subject: if the sections stop
|
|
611
|
+
// carrying any token the pattern scan does not already claim, the section
|
|
612
|
+
// contract silently stops being checked.
|
|
613
|
+
let tokens = 0
|
|
614
|
+
for (const { file, heading, label } of SUCCESS_CRITERIA_SECTIONS) {
|
|
615
|
+
const text = readFileSync(join(packageRoot, file), 'utf8')
|
|
616
|
+
const section = locateSection(text, heading)
|
|
617
|
+
assert.ok(section !== null, `${file}: no heading matches "${label}" - ${SECTION_ANCHOR_REMEDY}`)
|
|
618
|
+
const reportedLines = new Set(
|
|
619
|
+
collectReleaseReferences(text, file)
|
|
620
|
+
.filter((reference) => reference.problem !== null || reference.version !== manifest.version)
|
|
621
|
+
.map((reference) => `${reference.file}:${reference.line}`),
|
|
622
|
+
)
|
|
623
|
+
for (const token of collectSectionVersionTokens(text, file, section)) {
|
|
624
|
+
if (reportedLines.has(`${token.file}:${token.line}`)) continue
|
|
625
|
+
tokens += 1
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
assert.ok(
|
|
629
|
+
tokens > 0,
|
|
630
|
+
'the success-criteria sections carry no version token the section scan owns, so it checks ' +
|
|
631
|
+
'nothing. Either the section was hollowed out (a regression worth fixing) or the docs ' +
|
|
632
|
+
'intentionally stopped pinning a version - in that case retire this scan deliberately with ' +
|
|
633
|
+
'the docs owner instead of leaving a guard that cannot fail.',
|
|
634
|
+
)
|
|
635
|
+
})
|
|
636
|
+
|
|
68
637
|
test('declares the DSH compatibility range under engines', () => {
|
|
69
638
|
assert.equal(manifest.engines.node, NODE_RANGE)
|
|
70
639
|
// DSH_RANGE is deliberately spelled with a prerelease-aware upper bound.
|