dsh-dsml-rescue 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +7 -1
  2. package/scripts/doctor.mjs +275 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dsml-rescue",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Auto-continues turns stalled by DSML tool-call markup leaking into reasoning.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -23,6 +23,9 @@
23
23
  "type": "module",
24
24
  "main": "./lib/index.js",
25
25
  "types": "./lib/index.d.ts",
26
+ "bin": {
27
+ "dsh-dsml-rescue-doctor": "./scripts/doctor.mjs"
28
+ },
26
29
  "exports": {
27
30
  ".": {
28
31
  "types": "./lib/index.d.ts",
@@ -37,6 +40,7 @@
37
40
  "icon.svg",
38
41
  "locale/*.json",
39
42
  "cordis.patch.yml",
43
+ "scripts/doctor.mjs",
40
44
  "README.md",
41
45
  "LICENSE",
42
46
  "README_zh.md"
@@ -81,6 +85,8 @@
81
85
  "lint": "eslint .",
82
86
  "format": "prettier --write .",
83
87
  "format:check": "prettier --check .",
88
+ "doctor": "node scripts/doctor.mjs",
89
+ "check:declaration": "node scripts/check-declaration.mjs",
84
90
  "check:release": "node scripts/check-release.mjs",
85
91
  "check": "pnpm run typecheck && pnpm run build && pnpm run test && pnpm run check:release"
86
92
  }
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install check — will this plugin actually run, and is it the code you just wrote?
4
+ *
5
+ * Why this exists, and why it is **not** a runtime warning: an unmounted plugin has no code
6
+ * running, so it cannot report its own absence. The failure is silent by construction. A profile
7
+ * can list the package under `dependencies` and still never load it — the loader only mounts a
8
+ * package whose name is in `dsh.profile.bundles` (or that a profile patch inserts explicitly).
9
+ * A dependency alone contributes no patch layer and produces no diagnostic anywhere.
10
+ *
11
+ * Worse, the obvious repair does not work: re-running `dsh plugin add <same package>` skips
12
+ * packages that are already dependencies, so a lost bundle row is never restored by re-adding.
13
+ * The repair is remove-then-add, and this check prints exactly that.
14
+ *
15
+ * Two groups of checks:
16
+ * - **Mount state** — reads a profile's `package.json` and `cordis.patch.yml`.
17
+ * - **Artifact freshness** — a `link:` install runs `lib/` from this checkout, so a stale build
18
+ * silently runs code that is not in `src/`. Only applies when run from a repository checkout.
19
+ *
20
+ * Usage:
21
+ * node scripts/doctor.mjs # $DSH_HOME, $DSH_PROFILE, else ~/.dsh + web
22
+ * node scripts/doctor.mjs --profile web --home <DSH_HOME>
23
+ * node scripts/doctor.mjs --json # machine-readable
24
+ *
25
+ * Exit codes: 0 = mounted and current / 1 = not mounted, or the build is stale / 2 = precondition
26
+ * missing (no readable profile manifest).
27
+ *
28
+ * @module dsh-dsml-rescue/doctor
29
+ */
30
+
31
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
32
+ import { homedir } from 'node:os'
33
+ import { dirname, join, resolve } from 'node:path'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
+
36
+ /** The package this doctor is about; also the bundle-list entry the loader needs. */
37
+ export const PACKAGE = 'dsh-dsml-rescue'
38
+
39
+ /** One line of the report. */
40
+ export const OK = 'ok'
41
+ export const FAIL = 'fail'
42
+ export const NOTE = 'note'
43
+
44
+ /**
45
+ * Read one file as UTF-8, or undefined when it is absent or unreadable.
46
+ * @param {string} path - file to read.
47
+ * @returns {string | undefined} the text, or undefined.
48
+ */
49
+ function readText(path) {
50
+ try {
51
+ return readFileSync(path, 'utf8')
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read and parse a JSON file.
59
+ * @param {string} path - file to read.
60
+ * @returns {{ value?: any, error?: string }} the parsed value, or why it failed.
61
+ */
62
+ function readJson(path) {
63
+ const text = readText(path)
64
+ if (text === undefined) return { error: `cannot read ${path}` }
65
+ try {
66
+ return { value: JSON.parse(text) }
67
+ } catch (error) {
68
+ return { error: `${path} is not valid JSON: ${String(error)}` }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Whether a profile patch inserts this package as a row of its own.
74
+ *
75
+ * A patch row is a second, legitimate way to mount the plugin, so a profile that uses it does not
76
+ * need the bundle-list entry. The check is textual on purpose: reading YAML would mean shipping a
77
+ * parser to answer one question, and a comment mentioning the name must not count as a mount —
78
+ * hence the requirement that the name sits on an `id:`/`name:` line that starts a list item.
79
+ * @param {string | undefined} patchText - the profile's cordis.patch.yml.
80
+ * @param {string} name - package name.
81
+ * @returns {boolean} whether a row mounts it.
82
+ */
83
+ function patchMounts(patchText, name) {
84
+ if (patchText === undefined) return false
85
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
86
+ const row = new RegExp(`^\\s*-?\\s*(?:id|name)\\s*:\\s*['"]?${escaped}['"]?\\s*$`, 'm')
87
+ return row.test(patchText)
88
+ }
89
+
90
+ /**
91
+ * Judge whether the plugin is mounted, from the profile's own two files.
92
+ *
93
+ * Pure apart from nothing: it takes the manifest and the patch text and returns findings, so the
94
+ * rules can be tested without a home directory.
95
+ * @param {{ manifest: any, patchText: string | undefined, name?: string }} input - the profile state.
96
+ * @returns {{ level: string, message: string, hint?: string }[]} findings, most important first.
97
+ */
98
+ export function inspectProfile({ manifest, patchText, name = PACKAGE }) {
99
+ const findings = []
100
+ const dependencies = manifest?.dependencies ?? {}
101
+ const bundles = manifest?.dsh?.profile?.bundles ?? []
102
+ const inDependencies = Object.hasOwn(dependencies, name)
103
+ const inBundles = Array.isArray(bundles) && bundles.includes(name)
104
+ const viaPatch = patchMounts(patchText, name)
105
+
106
+ if (!inDependencies && !viaPatch) {
107
+ findings.push({
108
+ level: FAIL,
109
+ message: `${name} is not in this profile at all`,
110
+ hint: `install it: dsh plugin --profile <profile> add ${name}`,
111
+ })
112
+ return findings
113
+ }
114
+
115
+ if (inBundles) {
116
+ findings.push({
117
+ level: OK,
118
+ message: `${name} is in dsh.profile.bundles — the loader mounts it`,
119
+ })
120
+ } else if (viaPatch) {
121
+ findings.push({
122
+ level: OK,
123
+ message: `${name} is mounted by a row in the profile's cordis.patch.yml`,
124
+ hint: 'a patch row is enough; the bundle-list entry is only needed for a bundle install',
125
+ })
126
+ } else {
127
+ findings.push({
128
+ level: FAIL,
129
+ message: `${name} is a dependency but is NOT in dsh.profile.bundles — it will not load`,
130
+ hint: 'reinstalling does not repair this: `add` skips packages that are already dependencies. Run remove, then add.',
131
+ })
132
+ }
133
+
134
+ if (!inDependencies && viaPatch) {
135
+ findings.push({
136
+ level: NOTE,
137
+ message: 'no dependency entry: the loader resolves the row from somewhere else',
138
+ })
139
+ }
140
+
141
+ return findings
142
+ }
143
+
144
+ /**
145
+ * Whether the built artifact predates the newest source file.
146
+ *
147
+ * A `link:` install reads `lib/` straight out of the checkout, so the build is the code that runs.
148
+ * Editing `src/` without rebuilding therefore changes nothing at runtime, with no error anywhere.
149
+ * Reports nothing when either side is absent (an installed copy has no `src/`).
150
+ * @param {string} repoRoot - the package directory.
151
+ * @returns {{ level: string, message: string, hint?: string }[]} findings.
152
+ */
153
+ export function inspectArtifacts(repoRoot) {
154
+ const libDir = join(repoRoot, 'lib')
155
+ const srcDir = join(repoRoot, 'src')
156
+ if (!existsSync(libDir) || !existsSync(srcDir)) return []
157
+
158
+ /** The newest mtime of every file under `dir` with one of the given extensions. */
159
+ const newest = (dir, extensions) => {
160
+ let time = 0
161
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
162
+ const path = join(dir, entry.name)
163
+ if (entry.isDirectory()) {
164
+ time = Math.max(time, newest(path, extensions))
165
+ continue
166
+ }
167
+ if (!extensions.some((extension) => entry.name.endsWith(extension))) continue
168
+ try {
169
+ time = Math.max(time, statSync(path).mtimeMs)
170
+ } catch {
171
+ /* a file that vanished mid-scan is not a finding */
172
+ }
173
+ }
174
+ return time
175
+ }
176
+
177
+ const built = newest(libDir, ['.js'])
178
+ const source = newest(srcDir, ['.ts'])
179
+ if (built === 0) {
180
+ return [
181
+ {
182
+ level: FAIL,
183
+ message: 'lib/ holds no built JavaScript, so a link install has nothing to load',
184
+ hint: 'run `pnpm run build`',
185
+ },
186
+ ]
187
+ }
188
+ if (source > built) {
189
+ return [
190
+ {
191
+ level: FAIL,
192
+ message: 'src/ is newer than lib/ — a link install runs the stale build',
193
+ hint: 'run `pnpm run build`, then reload the plugin entry or restart the host',
194
+ },
195
+ ]
196
+ }
197
+ return [{ level: OK, message: 'the built artifact is current with src/' }]
198
+ }
199
+
200
+ /**
201
+ * Resolve the profile directory this run should inspect.
202
+ * @param {{ profile?: string, home?: string }} options - command-line options.
203
+ * @returns {{ profile: string, dir: string, home: string }} the resolved location.
204
+ */
205
+ export function resolveProfile(options = {}) {
206
+ const home = resolve(options.home ?? process.env['DSH_HOME'] ?? join(homedir(), '.dsh'))
207
+ const profile = options.profile ?? process.env['DSH_PROFILE'] ?? 'web'
208
+ return { profile, dir: join(home, 'profiles', profile), home }
209
+ }
210
+
211
+ /** Parse `--key value` and `--key=value` flags. */
212
+ function parseArgs(argv) {
213
+ const options = { json: false }
214
+ for (let index = 0; index < argv.length; index += 1) {
215
+ const argument = argv[index] ?? ''
216
+ if (argument === '--json') {
217
+ options.json = true
218
+ continue
219
+ }
220
+ const match = /^--(profile|home)(?:=(.*))?$/.exec(argument)
221
+ if (match === null) continue
222
+ const key = match[1]
223
+ const inline = match[2]
224
+ if (inline !== undefined) options[key] = inline
225
+ else {
226
+ options[key] = argv[index + 1]
227
+ index += 1
228
+ }
229
+ }
230
+ return options
231
+ }
232
+
233
+ /** Run the report. */
234
+ function main() {
235
+ const options = parseArgs(process.argv.slice(2))
236
+ const { profile, dir, home } = resolveProfile(options)
237
+ const manifestPath = join(dir, 'package.json')
238
+ const parsed = readJson(manifestPath)
239
+ if (parsed.error !== undefined) {
240
+ process.stderr.write(`doctor: ${parsed.error}\n`)
241
+ process.stderr.write(`doctor: is the profile name right? (home ${home}, profile ${profile})\n`)
242
+ process.exit(2)
243
+ }
244
+
245
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
246
+ const findings = [
247
+ ...inspectProfile({
248
+ manifest: parsed.value,
249
+ patchText: readText(join(dir, 'cordis.patch.yml')),
250
+ }),
251
+ ...inspectArtifacts(repoRoot),
252
+ ]
253
+ const failed = findings.some((finding) => finding.level === FAIL)
254
+
255
+ if (options.json) {
256
+ process.stdout.write(
257
+ `${JSON.stringify({ profile, home, mounted: !failed, findings }, undefined, 2)}\n`,
258
+ )
259
+ } else {
260
+ process.stdout.write(`doctor: profile ${profile} (${dir})\n`)
261
+ for (const finding of findings) {
262
+ const mark = finding.level === OK ? 'ok ' : finding.level === FAIL ? 'FAIL' : 'note'
263
+ process.stdout.write(` ${mark} ${finding.message}\n`)
264
+ if (finding.hint !== undefined) process.stdout.write(` ${finding.hint}\n`)
265
+ }
266
+ }
267
+ process.exit(failed ? 1 : 0)
268
+ }
269
+
270
+ if (
271
+ process.argv[1] !== undefined &&
272
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
273
+ ) {
274
+ main()
275
+ }