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.
@@ -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 command = name => process.platform === 'win32' ? `${name}.cmd` : name
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
- for (const version of verifiedDshVersions) {
21
- run('npm', ['run', 'test:profile'], { ...process.env, DSH_VERSION: version })
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
+ }