dsh-scout 0.9.0 → 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 +128 -0
- package/README.en.md +328 -41
- package/README.md +231 -37
- package/assets/icon.svg +7 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.js +299 -0
- package/dist/index.d.ts +31 -9
- package/dist/index.js +703 -206
- package/dist/model.d.ts +58 -8
- package/dist/model.js +260 -107
- package/docs/dsh-scout-product-contract.md +70 -5
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -7
- package/scripts/check-release.mjs +447 -4
- package/scripts/profile-smoke.mjs +1118 -41
- package/tests/acceptance.test.mjs +1744 -0
- package/tests/model.test.mjs +496 -778
- package/tests/package.test.mjs +815 -0
- package/tests/tools.test.mjs +974 -0
|
@@ -1,12 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-scout release gate.
|
|
4
|
+
*
|
|
5
|
+
* Runs the unit tests, the real-invocation profile smoke against every DSH
|
|
6
|
+
* version in the verified matrix, and the packaging dry run.
|
|
7
|
+
*
|
|
8
|
+
* DSH 0.9.1 addition — declared-range classification. `engines.dsh` in
|
|
9
|
+
* package.json is the declared compatibility window; `DSH compatibility is
|
|
10
|
+
* declarative until a reader enforces it` (dsh-package-manifest), so this gate
|
|
11
|
+
* is where the declaration is actually checked. Each matrix failure is now
|
|
12
|
+
* classified:
|
|
13
|
+
* - a failure INSIDE `engines.dsh` is a REGRESSION (the plugin used to work
|
|
14
|
+
* and the code or the host changed);
|
|
15
|
+
* - a failure OUTSIDE `engines.dsh` is reported with the `OUT OF DECLARED
|
|
16
|
+
* RANGE` prefix: it is expected drift when DSH publishes a new major line
|
|
17
|
+
* (for example `0.3.0-alpha.1` against `<0.3.0-0`), not a code regression.
|
|
18
|
+
* Set `DSH_ALLOW_OUT_OF_RANGE=1` to keep an out-of-range channel job green while
|
|
19
|
+
* still printing the distinct prefix.
|
|
20
|
+
*
|
|
21
|
+
* Environment:
|
|
22
|
+
* DSH_VERSIONS override the verified version matrix
|
|
23
|
+
* (comma- or whitespace-separated, e.g.
|
|
24
|
+
* `DSH_VERSIONS=0.2.0-rc.2 node scripts/check-release.mjs`)
|
|
25
|
+
* DSH_ALLOW_OUT_OF_RANGE `1` downgrades out-of-range failures to warnings
|
|
26
|
+
*
|
|
27
|
+
* README preflight (P2-4): the READMEs pin the released version in their install
|
|
28
|
+
* commands and artifact names, and nothing used to check them. The primary guard
|
|
29
|
+
* lives in `tests/package.test.mjs` (it runs on every `npm test` and in CI, in
|
|
30
|
+
* milliseconds); this gate repeats the check as an independent second
|
|
31
|
+
* implementation so a stale README fails in milliseconds instead of after the
|
|
32
|
+
* whole unit suite and the ~2 minute four-version profile matrix below.
|
|
33
|
+
*
|
|
34
|
+
* SemVer semantics (do NOT "fix" this matcher): this script has no runtime
|
|
35
|
+
* dependency, so it carries a small comparator-set matcher that mirrors
|
|
36
|
+
* node-semver's `{ includePrerelease: true }` mode — the exact mode DSH uses to
|
|
37
|
+
* evaluate plugin compatibility (`semver.satisfies(runtimeVersion, requirement,
|
|
38
|
+
* { includePrerelease: true })`, `@deepseek-ai/dsh-app-boot/lib/index.js:300`).
|
|
39
|
+
* In plain node-semver mode a prerelease version only satisfies a comparator set
|
|
40
|
+
* when some comparator shares its [major, minor, patch] tuple, which would
|
|
41
|
+
* reject the entire verified 0.2 prerelease line. Measured with semver 7.8.5
|
|
42
|
+
* against `engines.dsh = ">=0.1.0-rc.6 <0.3.0-0"`:
|
|
43
|
+
*
|
|
44
|
+
* version plain includePrerelease
|
|
45
|
+
* 0.1.0-rc.6 true true
|
|
46
|
+
* 0.1.2-rc.1 false true
|
|
47
|
+
* 0.2.0-rc.2 false true
|
|
48
|
+
* 0.2.1-alpha.2 false true
|
|
49
|
+
* 0.2.9 true true
|
|
50
|
+
* 0.3.0-alpha.1 false false
|
|
51
|
+
* 0.3.0 false false
|
|
52
|
+
*
|
|
53
|
+
* This matcher was differentially tested against semver 7.8.5 with
|
|
54
|
+
* `{ includePrerelease: true }` and agrees on every case above plus
|
|
55
|
+
* 0.1.0-rc.5 / 0.1.0 / 0.1.1 / 0.2.0-alpha.0 / 0.3.0-0 / 0.2.1 / 1.0.0 / 0.0.9.
|
|
56
|
+
* Unsupported range syntax (for example `1.x`, `*`, `latest`) is reported as
|
|
57
|
+
* UNKNOWN instead of being guessed.
|
|
58
|
+
* Note: `engines.dsh` itself is declarative in DSH 0.2.0-rc.2 (`dsh-package-manifest`:
|
|
59
|
+
* "DSH compatibility is declarative until a reader enforces it"); the range DSH
|
|
60
|
+
* actually enforces is the `peerDependencies` one at the call site above, so this
|
|
61
|
+
* gate is where the `engines.dsh` declaration is really checked.
|
|
62
|
+
*/
|
|
1
63
|
import { spawnSync } from 'node:child_process'
|
|
64
|
+
import { readFileSync } from 'node:fs'
|
|
2
65
|
|
|
3
|
-
const
|
|
4
|
-
const verifiedDshVersions = [
|
|
66
|
+
const DEFAULT_VERIFIED_DSH_VERSIONS = [
|
|
5
67
|
'0.1.0-rc.6',
|
|
6
68
|
'0.1.2-rc.1',
|
|
7
69
|
'0.2.0-rc.2',
|
|
8
70
|
'0.2.1-alpha.2',
|
|
9
71
|
]
|
|
72
|
+
const ALLOW_OUT_OF_RANGE = process.env.DSH_ALLOW_OUT_OF_RANGE === '1'
|
|
73
|
+
const command = name => process.platform === 'win32' ? `${name}.cmd` : name
|
|
74
|
+
|
|
75
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
76
|
+
const declaredRange = typeof packageJson.engines?.dsh === 'string' ? packageJson.engines.dsh.trim() : ''
|
|
77
|
+
|
|
78
|
+
function resolveVersions() {
|
|
79
|
+
const override = (process.env.DSH_VERSIONS ?? '').trim()
|
|
80
|
+
if (!override) return { versions: DEFAULT_VERIFIED_DSH_VERSIONS, source: 'default matrix' }
|
|
81
|
+
const versions = override.split(/[,\s]+/).filter(Boolean)
|
|
82
|
+
if (versions.length === 0) throw new Error(`DSH_VERSIONS is set but names no version: ${JSON.stringify(process.env.DSH_VERSIONS)}`)
|
|
83
|
+
return { versions, source: 'DSH_VERSIONS override' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- minimal SemVer comparator support (see the module doc for the deviation) --
|
|
87
|
+
|
|
88
|
+
const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
|
|
89
|
+
|
|
90
|
+
function parseVersion(text) {
|
|
91
|
+
const match = VERSION_PATTERN.exec(String(text).trim())
|
|
92
|
+
if (match === null) return null
|
|
93
|
+
return {
|
|
94
|
+
major: Number(match[1]),
|
|
95
|
+
minor: Number(match[2]),
|
|
96
|
+
patch: Number(match[3]),
|
|
97
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compareIdentifiers(a, b) {
|
|
102
|
+
const numeric = /^\d+$/
|
|
103
|
+
const aNumeric = numeric.test(a)
|
|
104
|
+
const bNumeric = numeric.test(b)
|
|
105
|
+
if (aNumeric && bNumeric) return Number(a) === Number(b) ? 0 : Number(a) < Number(b) ? -1 : 1
|
|
106
|
+
if (aNumeric) return -1
|
|
107
|
+
if (bNumeric) return 1
|
|
108
|
+
return a === b ? 0 : a < b ? -1 : 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function comparePrerelease(a, b) {
|
|
112
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
113
|
+
if (a.length === 0) return 1
|
|
114
|
+
if (b.length === 0) return -1
|
|
115
|
+
const length = Math.max(a.length, b.length)
|
|
116
|
+
for (let index = 0; index < length; index += 1) {
|
|
117
|
+
if (a[index] === undefined) return -1
|
|
118
|
+
if (b[index] === undefined) return 1
|
|
119
|
+
const order = compareIdentifiers(a[index], b[index])
|
|
120
|
+
if (order !== 0) return order
|
|
121
|
+
}
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function compareVersions(a, b) {
|
|
126
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1
|
|
127
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
|
|
128
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
|
|
129
|
+
return comparePrerelease(a.prerelease, b.prerelease)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseRange(text) {
|
|
133
|
+
const orSets = String(text).trim().split('||').map(part => part.trim()).filter(Boolean)
|
|
134
|
+
if (orSets.length === 0) return { sets: null, unsupported: String(text) }
|
|
135
|
+
const sets = []
|
|
136
|
+
for (const part of orSets) {
|
|
137
|
+
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(part)
|
|
138
|
+
const tokens = hyphen === null ? part.split(/\s+/).filter(Boolean) : [`>=${hyphen[1]}`, `<=${hyphen[2]}`]
|
|
139
|
+
const comparators = []
|
|
140
|
+
for (const token of tokens) {
|
|
141
|
+
const match = /^(>=|<=|>|<|=|\^|~)?(.*)$/.exec(token)
|
|
142
|
+
const operator = match[1] === undefined ? '=' : match[1]
|
|
143
|
+
const version = parseVersion(match[2])
|
|
144
|
+
if (version === null) return { sets: null, unsupported: token }
|
|
145
|
+
if (operator === '^' || operator === '~') {
|
|
146
|
+
comparators.push({ operator: '>=', version })
|
|
147
|
+
comparators.push({
|
|
148
|
+
operator: '<',
|
|
149
|
+
version: operator === '^'
|
|
150
|
+
? { major: version.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
|
|
151
|
+
: { major: version.major, minor: version.minor + 1, patch: 0, prerelease: ['0'] },
|
|
152
|
+
})
|
|
153
|
+
} else {
|
|
154
|
+
comparators.push({ operator, version })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
sets.push(comparators)
|
|
158
|
+
}
|
|
159
|
+
return { sets, unsupported: null }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function satisfiesSet(version, comparators) {
|
|
163
|
+
return comparators.every(comparator => {
|
|
164
|
+
const order = compareVersions(version, comparator.version)
|
|
165
|
+
switch (comparator.operator) {
|
|
166
|
+
case '>=': return order >= 0
|
|
167
|
+
case '>': return order > 0
|
|
168
|
+
case '<=': return order <= 0
|
|
169
|
+
case '<': return order < 0
|
|
170
|
+
case '=': return order === 0
|
|
171
|
+
default: return false
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Classify one DSH version against `engines.dsh`. */
|
|
177
|
+
function classifyVersion(version) {
|
|
178
|
+
if (declaredRange === '') return { status: 'unknown', reason: 'package.json declares no engines.dsh' }
|
|
179
|
+
const parsedVersion = parseVersion(version)
|
|
180
|
+
if (parsedVersion === null) return { status: 'unknown', reason: `not a SemVer version: ${version}` }
|
|
181
|
+
const range = parseRange(declaredRange)
|
|
182
|
+
if (range.sets === null) return { status: 'unknown', reason: `unsupported engines.dsh syntax near "${range.unsupported}"` }
|
|
183
|
+
const satisfied = range.sets.some(set => satisfiesSet(parsedVersion, set))
|
|
184
|
+
return satisfied
|
|
185
|
+
? { status: 'in-range', reason: `satisfies engines.dsh "${declaredRange}"` }
|
|
186
|
+
: { status: 'out-of-range', reason: `outside engines.dsh "${declaredRange}"` }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- gate ---------------------------------------------------------------------
|
|
10
190
|
|
|
11
191
|
function run(program, args, env = process.env) {
|
|
12
192
|
const result = spawnSync(command(program), args, { env, stdio: 'inherit' })
|
|
@@ -16,8 +196,271 @@ function run(program, args, env = process.env) {
|
|
|
16
196
|
}
|
|
17
197
|
}
|
|
18
198
|
|
|
199
|
+
const { versions, source } = resolveVersions()
|
|
200
|
+
console.log('dsh-scout release gate')
|
|
201
|
+
console.log(` declared engines.dsh: ${declaredRange === '' ? '(none declared — failures cannot be classified)' : declaredRange}`)
|
|
202
|
+
console.log(' range semantics: node-semver includePrerelease:true (mirrors dsh-app-boot evaluatePluginCompatibility:300)')
|
|
203
|
+
console.log(` profile matrix: ${versions.join(', ')} (${source})`)
|
|
204
|
+
console.log(` out-of-range failures: ${ALLOW_OUT_OF_RANGE ? 'warn only (DSH_ALLOW_OUT_OF_RANGE=1)' : 'fatal, reported as OUT OF DECLARED RANGE'}`)
|
|
205
|
+
|
|
206
|
+
// --- README release-reference preflight (see the module doc) ------------------
|
|
207
|
+
//
|
|
208
|
+
// Deliberately a second, simpler implementation of the guard in
|
|
209
|
+
// tests/package.test.mjs rather than a shared import: tests are not a library, and
|
|
210
|
+
// this one has to fail before the expensive work below. Same rule as that guard -
|
|
211
|
+
// only install/artifact references are checked: `dsh-scout-<v>.tgz`, the
|
|
212
|
+
// `dsh-scout@<v>` pin in its three live forms (install operand, alone on its
|
|
213
|
+
// line, or a quoted version key), the ` · dsh-scout <v>` status banner, a
|
|
214
|
+
// `#v<v>` git ref, and the phrase-anchored `本次发布创建 \`v<v>\`` /
|
|
215
|
+
// `this release creates \`v<v>\`` tag claim. Historical mentions in the
|
|
216
|
+
// verified-version matrix, the roadmap, and release prose are left alone.
|
|
217
|
+
// 0.9.3 freezes this rule set: a new version-reference style is evaluated in the
|
|
218
|
+
// change that introduces it, not by widening this list indefinitely.
|
|
219
|
+
const README_FILES = ['README.md', 'README.en.md']
|
|
220
|
+
const README_SEMVER = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
|
|
221
|
+
/** An exact specifier: `1.2.3` or `v1.2.3`. */
|
|
222
|
+
const README_EXACT_SPECIFIER = new RegExp(`^v?(${README_SEMVER})$`)
|
|
223
|
+
/**
|
|
224
|
+
* A specifier naming a version *or a range* (`0.9`, `0.9.x`, `^0.9.2`, `>=0.9.2`,
|
|
225
|
+
* `=0.9.2`, `*`). Ranges are live references node-semver satisfies, but this
|
|
226
|
+
* preflight carries no range semantics, so it fails closed rather than waving
|
|
227
|
+
* them through (F-1). Dist-tags and placeholders name no version and stay valid.
|
|
228
|
+
*/
|
|
229
|
+
const README_VERSION_OR_RANGE = /^[\^~<>=|\s]*(?:v?\d|\*|x)/i
|
|
230
|
+
/** Exact version, unsupported specifier, or not a version claim at all. */
|
|
231
|
+
const classifySpecifier = token => {
|
|
232
|
+
const exact = README_EXACT_SPECIFIER.exec(token)
|
|
233
|
+
if (exact !== null) return { version: exact[1] }
|
|
234
|
+
if (README_VERSION_OR_RANGE.test(token)) {
|
|
235
|
+
return {
|
|
236
|
+
problem:
|
|
237
|
+
`uses the unsupported specifier "${token}"; a range or comparison cannot be compared to ` +
|
|
238
|
+
'the released version - write the exact version, or extend README_REFERENCE_PATTERNS ' +
|
|
239
|
+
'with the semantics of this form',
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
const README_REFERENCE_PATTERNS = [
|
|
245
|
+
{
|
|
246
|
+
kind: 'tarball name',
|
|
247
|
+
pattern: new RegExp(`dsh-scout-(${README_SEMVER})\\.tgz`, 'g'),
|
|
248
|
+
classify: match => ({ version: match[1] }),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
kind: 'npm install pin',
|
|
252
|
+
// Token charset excludes sentence punctuation, so `dsh-scout@0.9.3。` still
|
|
253
|
+
// reads as an exact pin while `dsh-scout@^0.9.2` is captured whole.
|
|
254
|
+
pattern: new RegExp('dsh-scout@([0-9A-Za-z.*^~<>=|+-]+)', 'g'),
|
|
255
|
+
classify: (match, line) => {
|
|
256
|
+
const before = line.slice(0, match.index)
|
|
257
|
+
const after = line.slice(match.index + match[0].length)
|
|
258
|
+
// the operand of an install verb ...
|
|
259
|
+
const livePosition =
|
|
260
|
+
/(?:^|[\s`'"(])(?:add|install|dlx|i)\s+(?:--?[\w-]+(?:=\S+)?\s+)*(?:file:)?$/.test(before.replace(/[`"')\]]+$/, '')) ||
|
|
261
|
+
// ... a specifier alone on its line (the desktop app's plugin field) ...
|
|
262
|
+
(before.replace(/^[\s>]*(?:[-*+]\s+)?/, '') === '' && after.replace(/[`"')\]]+$/, '').trim() === '') ||
|
|
263
|
+
// ... or a quoted `"package@version"` key (compatibility.json).
|
|
264
|
+
(/["']$/.test(before) && /^["']\s*:/.test(after))
|
|
265
|
+
return livePosition ? classifySpecifier(match[1]) : null
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
kind: 'git install ref',
|
|
270
|
+
pattern: new RegExp(`dsh-scout(?:\\.git)?#v?(${README_SEMVER})`, 'g'),
|
|
271
|
+
classify: match => ({ version: match[1] }),
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
// ` · dsh-scout <v>` - the /scout status banner (src/index.ts appends
|
|
275
|
+
// ` · dsh-scout ${PLUGIN_VERSION}`); the separator is required so that prose
|
|
276
|
+
// and report titles naming the package next to a version are not pins.
|
|
277
|
+
kind: 'status banner',
|
|
278
|
+
pattern: new RegExp(`·\\s*dsh-scout (${README_SEMVER})`, 'g'),
|
|
279
|
+
classify: match => ({ version: match[1] }),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
// `本次发布创建 \`v<v>\`` / `this release creates \`v<v>\`` - the tag this
|
|
283
|
+
// release creates. The phrase is what makes a bare `v<semver>` safe to guard
|
|
284
|
+
// here: the same line also lists historical tags (`v0.3.0` ... `v0.9.2`).
|
|
285
|
+
kind: 'release tag claim',
|
|
286
|
+
pattern: new RegExp(`(?:本次发布创建|this release creates)\\s*\`?v?(${README_SEMVER})\`?`, 'g'),
|
|
287
|
+
classify: match => ({ version: match[1] }),
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
const expectedVersion = packageJson.version
|
|
291
|
+
const staleReferences = []
|
|
292
|
+
const reportedLines = new Set()
|
|
293
|
+
const anchorFailures = []
|
|
294
|
+
const releaseTagClaimFiles = new Set()
|
|
295
|
+
let readmeReferenceCount = 0
|
|
296
|
+
for (const file of README_FILES) {
|
|
297
|
+
const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
|
298
|
+
text.split('\n').forEach((line, lineIndex) => {
|
|
299
|
+
for (const { kind, pattern, classify } of README_REFERENCE_PATTERNS) {
|
|
300
|
+
pattern.lastIndex = 0
|
|
301
|
+
let match
|
|
302
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
303
|
+
const verdict = classify(match, line)
|
|
304
|
+
if (verdict === null) continue
|
|
305
|
+
if (kind === 'release tag claim') releaseTagClaimFiles.add(file)
|
|
306
|
+
readmeReferenceCount += 1
|
|
307
|
+
if (verdict.problem !== undefined) {
|
|
308
|
+
staleReferences.push(`${file}:${lineIndex + 1} ${match[0]} ${verdict.problem} (${kind})`)
|
|
309
|
+
// Only *offending* lines are deduped, so the section scan below still
|
|
310
|
+
// sees a stale bare token on a line that also carries a correct pin
|
|
311
|
+
// (F-3): this matches guardFailures() in tests/package.test.mjs.
|
|
312
|
+
reportedLines.add(`${file}:${lineIndex + 1}`)
|
|
313
|
+
} else if (verdict.version !== expectedVersion) {
|
|
314
|
+
staleReferences.push(
|
|
315
|
+
`${file}:${lineIndex + 1} pins ${match[0]} but package.json is ${expectedVersion} (${kind})`,
|
|
316
|
+
)
|
|
317
|
+
reportedLines.add(`${file}:${lineIndex + 1}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The release-tag claim is phrase-anchored, so rewording the sentence would stop
|
|
325
|
+
// guarding it silently. Asserted per file: a rule that can no longer match
|
|
326
|
+
// anything is the false green this preflight exists to prevent.
|
|
327
|
+
for (const file of README_FILES) {
|
|
328
|
+
if (!releaseTagClaimFiles.has(file)) {
|
|
329
|
+
anchorFailures.push(
|
|
330
|
+
`${file}: no "release tag claim" reference matched - either the sentence that creates the ` +
|
|
331
|
+
'release tag was reworded (update the phrase in README_REFERENCE_PATTERNS here and in ' +
|
|
332
|
+
'tests/package.test.mjs) or it was removed (retire this rule deliberately with the docs owner)',
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- success-criteria section scan (T-G) --------------------------------------
|
|
338
|
+
//
|
|
339
|
+
// The success-criteria section says how to confirm that *this* release is the one
|
|
340
|
+
// that loaded, so every version token inside it must be the released version -
|
|
341
|
+
// including the bare forms the pattern scan above cannot claim safely (the card's
|
|
342
|
+
// `v0.9.3` tag, `and version `0.9.3``), because a bare `v<semver>` elsewhere is
|
|
343
|
+
// legitimate history (the release-history line lists `v0.3.0` ... `v0.9.2`).
|
|
344
|
+
//
|
|
345
|
+
// The anchors are explicit and a miss is a FAILURE, never a skip: a silently
|
|
346
|
+
// skipped section would let a reworded heading disarm the guard, which is exactly
|
|
347
|
+
// the false green this preflight exists to prevent.
|
|
348
|
+
const SECTION_ANCHORS = [
|
|
349
|
+
{ file: 'README.md', heading: /^(#{2,4})\s*装上了吗(成功判据)$/, label: '装上了吗(成功判据)' },
|
|
350
|
+
{ file: 'README.en.md', heading: /^(#{2,4})\s*Did it install\? \(success criteria\)$/, label: 'Did it install? (success criteria)' },
|
|
351
|
+
]
|
|
352
|
+
// `@` is deliberately not excluded: a prose reference inside the section such as
|
|
353
|
+
// `` 安装 `dsh-scout@0.9.2` 后 `` names a version the pattern scan does not claim
|
|
354
|
+
// (it is not in an install position) and the section contract still covers it
|
|
355
|
+
// (F-2). `-` stays excluded so a tarball name is left to the pattern scan.
|
|
356
|
+
const SECTION_VERSION_TOKEN = /(?<![\w./-])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?![\w.-])/g
|
|
357
|
+
const SECTION_ANCHOR_REMEDY =
|
|
358
|
+
'the success-criteria heading was reworded or moved: update SECTION_ANCHORS here and ' +
|
|
359
|
+
'SUCCESS_CRITERIA_SECTIONS in tests/package.test.mjs. This preflight must never skip it silently.'
|
|
360
|
+
let sectionTokenCount = 0
|
|
361
|
+
/** Section body bounds for one heading anchor; null when the anchor no longer matches. */
|
|
362
|
+
const sectionBounds = (lines, heading) => {
|
|
363
|
+
let fenced = false
|
|
364
|
+
let start = -1
|
|
365
|
+
let level = 0
|
|
366
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
367
|
+
const trimmed = lines[index].trim()
|
|
368
|
+
// A `# ...` shell comment inside the section's fenced CLI example is body
|
|
369
|
+
// text, not a heading, so fences are tracked while looking for the end.
|
|
370
|
+
if (/^(?:```|~~~)/.test(trimmed)) {
|
|
371
|
+
fenced = !fenced
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (fenced) continue
|
|
375
|
+
const marker = /^(#{1,6})\s/.exec(trimmed)
|
|
376
|
+
if (start === -1) {
|
|
377
|
+
if (heading.test(trimmed)) {
|
|
378
|
+
start = index
|
|
379
|
+
level = /^#+/.exec(trimmed)[0].length
|
|
380
|
+
}
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
if (marker !== null && marker[1].length <= level) return { start, end: index }
|
|
384
|
+
}
|
|
385
|
+
return start === -1 ? null : { start, end: lines.length }
|
|
386
|
+
}
|
|
387
|
+
for (const { file, heading, label } of SECTION_ANCHORS) {
|
|
388
|
+
const lines = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n')
|
|
389
|
+
const section = sectionBounds(lines, heading)
|
|
390
|
+
if (section === null) {
|
|
391
|
+
anchorFailures.push(`${file}: no heading matches "${label}" - ${SECTION_ANCHOR_REMEDY}`)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
for (let index = section.start + 1; index < section.end; index += 1) {
|
|
395
|
+
const line = lines[index]
|
|
396
|
+
if (reportedLines.has(`${file}:${index + 1}`)) continue
|
|
397
|
+
SECTION_VERSION_TOKEN.lastIndex = 0
|
|
398
|
+
let match
|
|
399
|
+
while ((match = SECTION_VERSION_TOKEN.exec(line)) !== null) {
|
|
400
|
+
sectionTokenCount += 1
|
|
401
|
+
readmeReferenceCount += 1
|
|
402
|
+
if (match[1] !== expectedVersion) {
|
|
403
|
+
staleReferences.push(
|
|
404
|
+
`${file}:${index + 1} pins ${match[0]} in the success-criteria section but package.json is ${expectedVersion}`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (staleReferences.length > 0 || anchorFailures.length > 0) {
|
|
412
|
+
console.error('')
|
|
413
|
+
if (staleReferences.length > 0) {
|
|
414
|
+
console.error(`README release references are stale (package.json is ${expectedVersion}):`)
|
|
415
|
+
for (const reference of staleReferences) console.error(` ${reference}`)
|
|
416
|
+
}
|
|
417
|
+
if (anchorFailures.length > 0) {
|
|
418
|
+
console.error('README guard anchors/phrases no longer resolve:')
|
|
419
|
+
for (const failure of anchorFailures) console.error(` ${failure}`)
|
|
420
|
+
}
|
|
421
|
+
console.error(`dsh-scout release gate: FAIL - bump the install/artifact references before releasing`)
|
|
422
|
+
console.error('(the same rule is asserted on every `npm test` in tests/package.test.mjs)')
|
|
423
|
+
process.exit(1)
|
|
424
|
+
}
|
|
425
|
+
console.log(` readme release references: ${readmeReferenceCount} checked (${sectionTokenCount} inside the success-criteria section), all pinned to ${expectedVersion}`)
|
|
426
|
+
|
|
19
427
|
run('npm', ['test'])
|
|
20
|
-
|
|
21
|
-
|
|
428
|
+
|
|
429
|
+
const regressions = []
|
|
430
|
+
const outOfRangeFailures = []
|
|
431
|
+
const unknownRangeFailures = []
|
|
432
|
+
for (const version of versions) {
|
|
433
|
+
const classification = classifyVersion(version)
|
|
434
|
+
try {
|
|
435
|
+
run('npm', ['run', 'test:profile'], { ...process.env, DSH_VERSION: version })
|
|
436
|
+
console.log(`[ok] dsh ${version} — profile smoke passed (${classification.reason})`)
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (classification.status === 'out-of-range') {
|
|
439
|
+
console.error(`[expected drift] OUT OF DECLARED RANGE dsh ${version} — profile smoke failed ${classification.reason}`)
|
|
440
|
+
outOfRangeFailures.push({ version, reason: classification.reason })
|
|
441
|
+
} else if (classification.status === 'unknown') {
|
|
442
|
+
console.error(`[regression] dsh ${version} — profile smoke failed and the version cannot be classified (${classification.reason})`)
|
|
443
|
+
unknownRangeFailures.push({ version, reason: classification.reason })
|
|
444
|
+
} else {
|
|
445
|
+
console.error(`[regression] dsh ${version} — profile smoke failed inside the declared range`)
|
|
446
|
+
regressions.push({ version, reason: classification.reason })
|
|
447
|
+
}
|
|
448
|
+
}
|
|
22
449
|
}
|
|
450
|
+
|
|
23
451
|
run('pnpm', ['pack', '--dry-run'])
|
|
452
|
+
|
|
453
|
+
const fatal = regressions.length > 0 || unknownRangeFailures.length > 0 || (outOfRangeFailures.length > 0 && !ALLOW_OUT_OF_RANGE)
|
|
454
|
+
if (regressions.length > 0 || outOfRangeFailures.length > 0 || unknownRangeFailures.length > 0) {
|
|
455
|
+
console.error('')
|
|
456
|
+
console.error(`dsh-scout release gate: ${fatal ? 'FAIL' : 'PASS (with expected out-of-range drift)'}`)
|
|
457
|
+
for (const failure of regressions) console.error(` REGRESSION (declared range): dsh ${failure.version} — ${failure.reason}`)
|
|
458
|
+
for (const failure of unknownRangeFailures) console.error(` RANGE UNKNOWN (cannot classify): dsh ${failure.version} — ${failure.reason}`)
|
|
459
|
+
for (const failure of outOfRangeFailures) {
|
|
460
|
+
const kind = ALLOW_OUT_OF_RANGE ? 'OUT OF DECLARED RANGE (warn only)' : 'OUT OF DECLARED RANGE'
|
|
461
|
+
console.error(` ${kind}: dsh ${failure.version} — ${failure.reason}; widen engines.dsh only after verifying the new line`)
|
|
462
|
+
}
|
|
463
|
+
if (regressions.length > 0 || unknownRangeFailures.length > 0 || !ALLOW_OUT_OF_RANGE) process.exitCode = 1
|
|
464
|
+
} else {
|
|
465
|
+
console.log(`dsh-scout release gate: PASS (${versions.length} DSH versions, ${declaredRange || 'no declared range'})`)
|
|
466
|
+
}
|