dsh-plugin-cicd 0.0.0-stage → 0.5.1

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.
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bundle contract check for a DeepSeek Harness profile bundle.
4
+ *
5
+ * Why this exists: the expensive failure in this family is never a crash. It is
6
+ * a package that installs cleanly, reports success, and then loads nothing —
7
+ * `dsh.bundle.patch` pointing at a file that was renamed, an `exports` target
8
+ * that no longer exists, a `files` entry that never made it into the tarball.
9
+ * None of those are visible until somebody installs the release. This checker
10
+ * turns them into a red build before the package is published.
11
+ *
12
+ * Usage:
13
+ * node scripts/verify-bundle.mjs [root]
14
+ *
15
+ * `root` defaults to the current directory, so the same file can be pointed at
16
+ * an unpacked release asset to prove the artifact — not the checkout — is sound.
17
+ *
18
+ * Exit 0 when the manifest is internally consistent; exit 1 with one `FAIL` line
19
+ * per hard violation. Warnings are advisory and never fail the run.
20
+ */
21
+
22
+ import { execFileSync } from 'node:child_process'
23
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
24
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
25
+
26
+ const ROOT = resolve(process.argv[2] ?? process.cwd())
27
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
28
+
29
+ const failures = []
30
+ const warnings = []
31
+ const fail = (message) => failures.push(message)
32
+ const warn = (message) => warnings.push(message)
33
+
34
+ const manifestPath = join(ROOT, 'package.json')
35
+ if (!existsSync(manifestPath)) {
36
+ console.error(`verify-bundle: FAIL — no package.json under ${ROOT}`)
37
+ process.exit(1)
38
+ }
39
+
40
+ let manifest
41
+ try {
42
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
43
+ } catch (error) {
44
+ console.error(`verify-bundle: FAIL — package.json is not valid JSON: ${error.message}`)
45
+ process.exit(1)
46
+ }
47
+
48
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
49
+
50
+ /** Match one path segment that may contain `*` wildcards against a directory. */
51
+ function matchSegment(dir, segment, rest) {
52
+ const matcher = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
53
+ const found = []
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ if (!matcher.test(entry.name)) continue
56
+ const absolute = join(dir, entry.name)
57
+ if (rest.length === 0) {
58
+ if (entry.isFile()) found.push(absolute)
59
+ } else if (entry.isDirectory()) {
60
+ found.push(...matchUnder(absolute, rest))
61
+ }
62
+ }
63
+ return found
64
+ }
65
+
66
+ /** Resolve the remainder of a subpath pattern relative to an already-open directory. */
67
+ function matchUnder(dir, segments) {
68
+ const [head, ...rest] = segments
69
+ if (head === undefined || head === '') return []
70
+ if (head.includes('*')) return matchSegment(dir, head, rest)
71
+ const next = join(dir, head)
72
+ if (!existsSync(next)) return []
73
+ if (rest.length === 0) return statSync(next).isFile() ? [next] : []
74
+ return statSync(next).isDirectory() ? matchUnder(next, rest) : []
75
+ }
76
+
77
+ /**
78
+ * Expand one `exports` / `files` entry.
79
+ *
80
+ * Subpath patterns (`./locale/*.json`) are legitimate and common — the loader
81
+ * addresses individual files through them — so a literal `existsSync` on the
82
+ * pattern itself reports a false failure. Everything before the first wildcard
83
+ * is a fixed prefix that must exist; the wildcards must then match real files.
84
+ */
85
+ function expandEntry(relative) {
86
+ const segments = relative.replace(/^\.\//, '').split('/')
87
+ const fixed = []
88
+ for (const segment of segments) {
89
+ if (segment.includes('*')) break
90
+ fixed.push(segment)
91
+ }
92
+ if (fixed.length === segments.length) return existsSync(join(ROOT, relative)) ? [join(ROOT, relative)] : []
93
+ const base = join(ROOT, ...fixed)
94
+ if (base === ROOT && fixed.length === 0) return []
95
+ if (!existsSync(base) || !statSync(base).isDirectory()) return []
96
+ return matchUnder(base, segments.slice(fixed.length))
97
+ }
98
+
99
+ /** Resolve one workspace-relative path and report it when it is not a real file. */
100
+ function requireFile(relative, source) {
101
+ if (typeof relative !== 'string' || relative === '') {
102
+ fail(`${source}: expected a path, got ${JSON.stringify(relative)}`)
103
+ return null
104
+ }
105
+ const absolute = join(ROOT, relative)
106
+ const matches = expandEntry(relative)
107
+ if (matches.length === 0) {
108
+ fail(
109
+ relative.includes('*')
110
+ ? `${source} pattern matches nothing: ${relative}`
111
+ : `${source} points at a missing file: ${relative}`,
112
+ )
113
+ return null
114
+ }
115
+ const first = matches[0]
116
+ if (!statSync(first).isFile()) {
117
+ fail(`${source} points at a directory, not a file: ${relative}`)
118
+ return null
119
+ }
120
+ void absolute
121
+ return first
122
+ }
123
+
124
+ // 1. Identity. A release is addressed by these two strings, so neither may drift.
125
+ if (typeof manifest.name !== 'string' || manifest.name.trim() === '') {
126
+ fail('package.json: `name` is missing')
127
+ }
128
+ if (typeof manifest.version !== 'string' || !SEMVER.test(manifest.version)) {
129
+ fail(`package.json: \`version\` is missing or not semver: ${JSON.stringify(manifest.version)}`)
130
+ }
131
+
132
+ // 2. The bundle patch is the whole reason a profile composes this package.
133
+ const patchRelative = manifest.dsh?.bundle?.patch
134
+ if (typeof patchRelative !== 'string' || patchRelative === '') {
135
+ fail('package.json: `dsh.bundle.patch` is missing — without it the profile never composes this package')
136
+ } else {
137
+ const patchPath = requireFile(patchRelative, 'dsh.bundle.patch')
138
+ if (patchPath !== null) {
139
+ const text = readFileSync(patchPath, 'utf8')
140
+ if (text.trim() === '') {
141
+ fail(`the bundle patch is empty: ${patchRelative}`)
142
+ } else if (typeof manifest.name === 'string' && !text.includes(manifest.name)) {
143
+ warn(`the bundle patch never names "${manifest.name}"; confirm the row it inserts is the one the profile loads`)
144
+ }
145
+ }
146
+ }
147
+
148
+ // 3. Every published entry point must exist. `exports` is what the loader reads.
149
+ const exportsField = manifest.exports
150
+ const exportEntries = []
151
+ if (exportsField === undefined) {
152
+ warn('package.json: no `exports` map; the loader reaches the browser half through its `./client` subpath')
153
+ } else if (typeof exportsField === 'string') {
154
+ exportEntries.push(['exports', exportsField])
155
+ } else if (exportsField !== null && typeof exportsField === 'object') {
156
+ for (const [key, value] of Object.entries(exportsField)) {
157
+ if (typeof value === 'string') exportEntries.push([`exports["${key}"]`, value])
158
+ }
159
+ } else {
160
+ fail('package.json: `exports` is neither a string nor a map')
161
+ }
162
+
163
+ const resolvedExports = new Map()
164
+ for (const [source, value] of exportEntries) {
165
+ if (!value.startsWith('./')) continue
166
+ const absolute = requireFile(value, source)
167
+ if (absolute !== null) resolvedExports.set(source, { relative: value, absolute })
168
+ }
169
+
170
+ // 4. `files` is the publish allow-list: an entry that does not exist is a typo,
171
+ // and a real file missing from it is content the release will not carry.
172
+ //
173
+ // That second half is the one that bites. A new local module imported by an
174
+ // entry point is invisible until somebody installs the tarball, at which point
175
+ // the import fails and the plugin never loads — the checkout works, so nothing
176
+ // local contradicts it. So the entry points' relative imports are walked and
177
+ // every one of them must be covered by the allow-list.
178
+ if (manifest.files !== undefined) {
179
+ if (!Array.isArray(manifest.files)) {
180
+ fail('package.json: `files` must be an array')
181
+ } else {
182
+ for (const entry of manifest.files) {
183
+ if (typeof entry !== 'string' || entry === '') {
184
+ fail(`package.json: invalid \`files\` entry: ${JSON.stringify(entry)}`)
185
+ continue
186
+ }
187
+ if (expandEntry(entry).length === 0) fail(`\`files\` entry resolves to nothing: ${entry}`)
188
+ }
189
+ }
190
+ } else {
191
+ warn('package.json: no `files` allow-list; the published package carries every tracked file')
192
+ }
193
+
194
+ /**
195
+ * Walk the relative imports of one file, transitively.
196
+ *
197
+ * Only static `from '...'` forms are matched: those are what an entry point uses
198
+ * to reach its own modules, and a dynamic import would be a finding of its own.
199
+ */
200
+ function relativeImports(file, seen = new Set()) {
201
+ if (seen.has(file) || !existsSync(file)) return seen
202
+ seen.add(file)
203
+ const text = readFileSync(file, 'utf8')
204
+ // Match the specifier, not the statement. A multi-line `import { … } from '…'`
205
+ // is common, and an expression trying to span the whole statement also spans
206
+ // unrelated code between two of them — which produced a false report about a
207
+ // file nobody imports.
208
+ const pattern = /\bfrom\s*['"](\.[^'"]+)['"]|\bimport\s*\(\s*['"](\.[^'"]+)['"]/g
209
+ for (const match of text.matchAll(pattern)) {
210
+ const specifier = match[1] ?? match[2]
211
+ if (specifier === undefined) continue
212
+ const resolved = resolve(dirname(file), specifier)
213
+ for (const candidate of [resolved, `${resolved}.mjs`, `${resolved}.js`, join(resolved, 'index.mjs')]) {
214
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
215
+ relativeImports(candidate, seen)
216
+ break
217
+ }
218
+ }
219
+ }
220
+ return seen
221
+ }
222
+
223
+ /** Whether a workspace-relative path is carried by the `files` allow-list. */
224
+ function isCarried(relativePath, allowList) {
225
+ if (allowList === null) return true
226
+ for (const entry of allowList) {
227
+ const normalized = entry.replace(/\/+$/, '')
228
+ if (relativePath === normalized || relativePath.startsWith(`${normalized}/`)) return true
229
+ }
230
+ return false
231
+ }
232
+
233
+ if (Array.isArray(manifest.files)) {
234
+ const allowList = manifest.files.filter((entry) => typeof entry === 'string')
235
+ const carried = new Set()
236
+ for (const [source, value] of exportEntries) {
237
+ if (typeof value !== 'string' || !value.startsWith('./')) continue
238
+ // Only JavaScript entry points import anything. `exports["./package.json"]` is
239
+ // an export target too, but it is data — and npm always ships it, listed or not.
240
+ if (!/\.(mjs|js)$/.test(value)) continue
241
+ const absolute = join(ROOT, value)
242
+ if (!existsSync(absolute)) continue
243
+ for (const file of relativeImports(absolute)) {
244
+ // `rel`, not `relative`: the latter is the imported path helper.
245
+ const rel = relative(ROOT, file).split(sep).join('/')
246
+ if (!isCarried(rel, allowList)) carried.add(rel)
247
+ }
248
+ }
249
+ if (carried.size > 0) {
250
+ fail(`these files are imported by an entry point but are not in \`files\`, so the published package would fail to load: ${[...carried].join(', ')}`)
251
+ }
252
+ }
253
+
254
+ // 5. The browser half is declared in `dsh.client` and delivered by `./client`.
255
+ const clientField = manifest.dsh?.client
256
+ if (clientField !== undefined) {
257
+ const target = exportEntries.find(([source]) => source === 'exports["./client"]')
258
+ if (target === undefined) {
259
+ fail('`dsh.client` is declared but package.json has no `./client` export')
260
+ } else if (!resolvedExports.has('exports["./client"]')) {
261
+ // requireFile already recorded the precise reason.
262
+ } else if (clientField?.platform !== 'web') {
263
+ warn(`dsh.client.platform is ${JSON.stringify(clientField?.platform)}; the loader expects "web"`)
264
+ }
265
+ }
266
+
267
+ // 6. Parse every entry point. `--check` parses without executing, which is what
268
+ // this needs: the browser half references `window` and the host half imports
269
+ // the harness, so neither can be imported here.
270
+ const syntaxTargets = new Map()
271
+ for (const [source, value] of exportEntries) {
272
+ if (typeof value === 'string' && value.endsWith('.js') && !value.includes('*')) syntaxTargets.set(value, source)
273
+ }
274
+ if (typeof manifest.main === 'string' && manifest.main.endsWith('.js')) {
275
+ syntaxTargets.set(manifest.main, 'main')
276
+ }
277
+ for (const relative of ['index.js', 'client.js']) {
278
+ if (existsSync(join(ROOT, relative))) syntaxTargets.set(`./${relative}`, 'top-level entry')
279
+ }
280
+
281
+ let parsed = 0
282
+ for (const [relative, source] of syntaxTargets) {
283
+ const absolute = join(ROOT, relative)
284
+ if (!existsSync(absolute) || !statSync(absolute).isFile()) {
285
+ fail(`${source} points at a missing entry file: ${relative}`)
286
+ continue
287
+ }
288
+ try {
289
+ execFileSync(process.execPath, ['--check', absolute], { stdio: ['ignore', 'ignore', 'pipe'] })
290
+ parsed += 1
291
+ } catch (error) {
292
+ const detail = (error.stderr ?? '').toString().trim().split('\n').slice(0, 6).join('\n')
293
+ fail(`entry file does not parse: ${relative}${detail === '' ? '' : `\n${detail}`}`)
294
+ }
295
+ }
296
+
297
+ // 7. Advisory presentation. DSH renders these on the plugin page; a missing one
298
+ // is a worse first impression, not a broken install.
299
+ if (typeof manifest.meta?.title !== 'string' || manifest.meta.title === '') {
300
+ warn('package.json: `meta.title` is missing — the plugin list falls back to the package name')
301
+ }
302
+ if (typeof manifest.meta?.description !== 'string' || manifest.meta.description === '') {
303
+ warn('package.json: `meta.description` is missing — the plugin page shows no summary')
304
+ }
305
+ if (typeof manifest.icon === 'string' && manifest.icon !== '' && !existsSync(join(ROOT, manifest.icon))) {
306
+ warn(`package.json: \`icon\` points at a missing file: ${manifest.icon}`)
307
+ }
308
+ if (typeof manifest.engines?.node !== 'string') {
309
+ warn('package.json: `engines.node` is missing; the supported runtime is undocumented')
310
+ }
311
+
312
+ const identity = `${manifest.name ?? '<unnamed>'}@${manifest.version ?? '<unversioned>'}`
313
+ console.log(`verify-bundle: ${identity} (${ROOT})`)
314
+ for (const warning of warnings) console.log(` warn ${warning}`)
315
+ for (const failure of failures) console.log(` FAIL ${failure}`)
316
+ if (failures.length > 0) {
317
+ console.error(`verify-bundle: ${failures.length} contract violation(s) in ${identity}`)
318
+ process.exit(1)
319
+ }
320
+ console.log(`verify-bundle: OK — ${resolvedExports.size} export target(s) resolved, ${parsed} entry file(s) parsed`)