mailwoman 7.4.0 → 7.5.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.
- package/README.md +4 -0
- package/commands/doctor.tsx +120 -0
- package/doctor/checks.ts +363 -0
- package/doctor/runner.ts +277 -0
- package/out/commands/doctor.d.ts +28 -0
- package/out/commands/doctor.d.ts.map +1 -0
- package/out/commands/doctor.js +80 -0
- package/out/commands/doctor.js.map +1 -0
- package/out/doctor/checks.d.ts +159 -0
- package/out/doctor/checks.d.ts.map +1 -0
- package/out/doctor/checks.js +216 -0
- package/out/doctor/checks.js.map +1 -0
- package/out/doctor/runner.d.ts +74 -0
- package/out/doctor/runner.d.ts.map +1 -0
- package/out/doctor/runner.js +192 -0
- package/out/doctor/runner.js.map +1 -0
- package/package.json +25 -25
package/README.md
CHANGED
|
@@ -76,6 +76,10 @@ mailwoman registry --sources config.json --out entities.geojson
|
|
|
76
76
|
|
|
77
77
|
# Interactive TUI
|
|
78
78
|
mailwoman parse --tui
|
|
79
|
+
|
|
80
|
+
# Diagnose the install — what works, what's missing, and the one command to fix each gap
|
|
81
|
+
mailwoman doctor # human-readable checklist (exit 1 if a core check fails)
|
|
82
|
+
mailwoman doctor --json # machine-readable { checks: [{ id, label, status, detail, fix?, core }], exitCode }
|
|
79
83
|
```
|
|
80
84
|
|
|
81
85
|
## Library API
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `mailwoman doctor [--json]` — the out-of-box diagnostic. A consumer who just ran `npm i mailwoman`
|
|
7
|
+
* runs this and learns exactly what works, what's missing, and the ONE command that fixes each gap.
|
|
8
|
+
* Six checks: model weights, the fr-fr locale overlay (informational), the data root, the admin
|
|
9
|
+
* gazetteer, the POI layer, and the Node + ONNX runtime.
|
|
10
|
+
*
|
|
11
|
+
* Exit-code contract (meaning-of-zero, per `checks.ts`):
|
|
12
|
+
*
|
|
13
|
+
* - 0 when every CORE check (weights + runtime) is `ok` — parse is ready, even with no data layers.
|
|
14
|
+
* - 1 when any core check is not `ok`.
|
|
15
|
+
*
|
|
16
|
+
* Data layers (data root, gazetteer, POI) are OPTIONAL: a gap is reported as missing/degraded with a
|
|
17
|
+
* fix hint, never a hard failure — parse runs without them. All verdict logic is pure and unit-tested
|
|
18
|
+
* in `doctor/checks.ts`; the IO seams live in `doctor/runner.ts`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Box, Text } from "ink"
|
|
22
|
+
import type * as React from "react"
|
|
23
|
+
import zod from "zod"
|
|
24
|
+
|
|
25
|
+
import { type CommandComponent, useCommandTask } from "../cli-kit/index.ts"
|
|
26
|
+
import { CheckStatus, type DoctorCheck, type DoctorReport } from "../doctor/checks.ts"
|
|
27
|
+
import { runDoctor } from "../doctor/runner.ts"
|
|
28
|
+
|
|
29
|
+
const OptionsSchema = zod.object({
|
|
30
|
+
json: zod
|
|
31
|
+
.boolean()
|
|
32
|
+
.optional()
|
|
33
|
+
.default(false)
|
|
34
|
+
.describe(
|
|
35
|
+
"Emit the report as JSON instead of a checklist: { checks: [{ id, label, status, detail, fix?, core }], exitCode } " +
|
|
36
|
+
"— a superset of { id, status, detail, fix? } (label + core aid machine consumers)."
|
|
37
|
+
),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export { OptionsSchema as options }
|
|
41
|
+
|
|
42
|
+
/** The status glyph + ink color for a check outcome. */
|
|
43
|
+
function statusGlyph(status: CheckStatus): { glyph: string; color: string } {
|
|
44
|
+
switch (status) {
|
|
45
|
+
case CheckStatus.OK:
|
|
46
|
+
return { glyph: "✓", color: "green" }
|
|
47
|
+
case CheckStatus.Degraded:
|
|
48
|
+
return { glyph: "⚠", color: "yellow" }
|
|
49
|
+
case CheckStatus.Missing:
|
|
50
|
+
return { glyph: "✗", color: "red" }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One check row: the glyph + label + detail, plus an indented `fix:` line and an `(optional)` tag for non-core gaps. */
|
|
55
|
+
function CheckRow({ check }: { check: DoctorCheck }): React.ReactElement {
|
|
56
|
+
const { glyph, color } = statusGlyph(check.status)
|
|
57
|
+
const optional = !check.core && check.status !== CheckStatus.OK ? " (optional)" : ""
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Box flexDirection="column">
|
|
61
|
+
<Text color={color}>
|
|
62
|
+
{glyph} {check.label}: {check.detail}
|
|
63
|
+
{optional}
|
|
64
|
+
</Text>
|
|
65
|
+
{check.fix ? <Text color="gray">{` fix: ${check.fix}`}</Text> : null}
|
|
66
|
+
</Box>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The rendered checklist + PASS/FAIL summary. */
|
|
71
|
+
function Report({ report }: { report: DoctorReport }): React.ReactElement {
|
|
72
|
+
const pass = report.exitCode === 0
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column">
|
|
76
|
+
<Text bold>mailwoman doctor</Text>
|
|
77
|
+
<Text> </Text>
|
|
78
|
+
{report.checks.map((check) => (
|
|
79
|
+
<CheckRow key={check.id} check={check} />
|
|
80
|
+
))}
|
|
81
|
+
<Text> </Text>
|
|
82
|
+
<Text color={pass ? "green" : "red"} bold>
|
|
83
|
+
{pass ? "PASS — core checks ok (weights + runtime); parse is ready" : "FAIL — a core check is not ok"}
|
|
84
|
+
</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DoctorCommand: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
90
|
+
// `--json` writes the report straight to stdout via `console.log` (mirroring `mailwoman openapi`) and
|
|
91
|
+
// renders NOTHING through Ink — Ink's `<Text>` hard-wraps to the terminal width, which would inject
|
|
92
|
+
// newlines mid-string and corrupt the JSON when piped. The checklist form renders through Ink normally.
|
|
93
|
+
const state = useCommandTask<DoctorReport>(
|
|
94
|
+
async () => {
|
|
95
|
+
const report = await runDoctor()
|
|
96
|
+
|
|
97
|
+
if (options.json) {
|
|
98
|
+
console.log(JSON.stringify(report, null, 2))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return report
|
|
102
|
+
},
|
|
103
|
+
(report) => report.exitCode
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if (state.status === "error") {
|
|
107
|
+
return <Text color="red">{state.message}</Text>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// JSON payload already emitted in the task; give Ink nothing to draw (transient frames would pollute stdout).
|
|
111
|
+
if (options.json) return null
|
|
112
|
+
|
|
113
|
+
if (state.status !== "done") {
|
|
114
|
+
return <Text color="gray">running diagnostics…</Text>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return <Report report={state.result} />
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default DoctorCommand
|
package/doctor/checks.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Pure decision logic for `mailwoman doctor` — the out-of-box diagnostic. Each `*Check` function
|
|
7
|
+
* takes a plain OBSERVATION object (facts already gathered from the filesystem/runtime by
|
|
8
|
+
* {@link ../doctor/runner.ts}) and returns a {@link DoctorCheck}. Keeping the verdict logic pure —
|
|
9
|
+
* no `fs`, no `import`, no env — is what makes it unit-testable without rendering Ink or standing up
|
|
10
|
+
* a data root: the runner injects the IO seams, this module owns only the ok/missing/degraded call.
|
|
11
|
+
*
|
|
12
|
+
* Meaning-of-zero discipline (memory: feedback-meaning-of-zero): a missing OPTIONAL layer reports as
|
|
13
|
+
* `missing`/`degraded` with a fix hint, never as a hard error. Only the CORE checks (weights +
|
|
14
|
+
* runtime) drive the process exit code — parse works without a data root, gazetteer, or POI layer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A check's outcome. `ok` = works; `missing` = absent but fixable; `degraded` = present but impaired. */
|
|
18
|
+
export const CheckStatus = {
|
|
19
|
+
OK: "ok",
|
|
20
|
+
Missing: "missing",
|
|
21
|
+
Degraded: "degraded",
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type CheckStatus = (typeof CheckStatus)[keyof typeof CheckStatus]
|
|
25
|
+
|
|
26
|
+
/** One diagnostic line: a stable `id`, its `status`, a human `detail`, and (when not ok) the one command that fixes it. */
|
|
27
|
+
export interface DoctorCheck {
|
|
28
|
+
id: string
|
|
29
|
+
/** Human-facing label for the check (rendered in the checklist). */
|
|
30
|
+
label: string
|
|
31
|
+
status: CheckStatus
|
|
32
|
+
detail: string
|
|
33
|
+
/** The single command/URL that closes the gap. Present whenever `status !== "ok"`. */
|
|
34
|
+
fix?: string
|
|
35
|
+
/**
|
|
36
|
+
* Whether this check gates the exit code. Core checks (weights + runtime) must be `ok` for a `0` exit; optional
|
|
37
|
+
* data-layer checks report their gap but never fail the process (parse runs without them).
|
|
38
|
+
*/
|
|
39
|
+
core: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The full diagnostic report — the checklist plus the derived exit code. */
|
|
43
|
+
export interface DoctorReport {
|
|
44
|
+
checks: DoctorCheck[]
|
|
45
|
+
exitCode: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A parsed `<major>.<minor>.<patch>` triple. */
|
|
49
|
+
export interface SemverTriple {
|
|
50
|
+
major: number
|
|
51
|
+
minor: number
|
|
52
|
+
patch: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse the minimum version out of a package.json `engines.node` range (`">=24.18.0"`, `"24.18.0"`, `">= 24"`). Returns
|
|
57
|
+
* `undefined` when no `<major>[.<minor>[.<patch>]]` is findable. Only the floor matters for the doctor — a
|
|
58
|
+
* caret/tilde/comparator prefix is stripped and missing minor/patch default to 0.
|
|
59
|
+
*/
|
|
60
|
+
export function parseVersionFloor(engines: string): SemverTriple | undefined {
|
|
61
|
+
const match = engines.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/u)
|
|
62
|
+
|
|
63
|
+
if (!match) return undefined
|
|
64
|
+
|
|
65
|
+
return { major: Number(match[1]), minor: Number(match[2] ?? 0), patch: Number(match[3] ?? 0) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse a bare `<major>.<minor>.<patch>` runtime version (e.g. `process.versions.node`). `undefined` if unparseable. */
|
|
69
|
+
export function parseVersion(version: string): SemverTriple | undefined {
|
|
70
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/u)
|
|
71
|
+
|
|
72
|
+
if (!match) return undefined
|
|
73
|
+
|
|
74
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `true` when `version` is at least `floor` under lexicographic major→minor→patch comparison. */
|
|
78
|
+
export function versionMeetsFloor(version: string, floor: string): boolean {
|
|
79
|
+
const v = parseVersion(version)
|
|
80
|
+
const f = parseVersionFloor(floor)
|
|
81
|
+
|
|
82
|
+
if (!v || !f) return false
|
|
83
|
+
|
|
84
|
+
if (v.major !== f.major) return v.major > f.major
|
|
85
|
+
|
|
86
|
+
if (v.minor !== f.minor) return v.minor > f.minor
|
|
87
|
+
|
|
88
|
+
return v.patch >= f.patch
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Bytes → a compact `12.3 MB` / `640 KB` / `12 B` string. */
|
|
92
|
+
export function formatBytes(bytes: number): string {
|
|
93
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
|
|
94
|
+
|
|
95
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB`
|
|
96
|
+
|
|
97
|
+
return `${bytes} B`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Observations (facts the runner gathers) → checks (verdicts)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Facts about the `@mailwoman/neural-weights-en-us` resolution. */
|
|
105
|
+
export interface WeightsObservation {
|
|
106
|
+
/** Resolved paths + source tag, or absent when resolution threw. */
|
|
107
|
+
resolved?: { source: string; modelPath: string; tokenizerPath: string }
|
|
108
|
+
/** Byte size of the resolved `model.onnx` (undefined if unresolved/unstattable). */
|
|
109
|
+
modelSize?: number
|
|
110
|
+
/** Byte size of the resolved `tokenizer.model`. */
|
|
111
|
+
tokenizerSize?: number
|
|
112
|
+
/** The resolution error message, when resolution failed. */
|
|
113
|
+
error?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const WEIGHTS_FIX = "npm install @mailwoman/neural-weights-en-us (or: mailwoman parse --download-weights)"
|
|
117
|
+
|
|
118
|
+
/** Check #1 — the trained model bundle. CORE: parse cannot run without it. */
|
|
119
|
+
export function weightsCheck(o: WeightsObservation): DoctorCheck {
|
|
120
|
+
const base = { id: "weights", label: "Model weights (en-us)", core: true }
|
|
121
|
+
|
|
122
|
+
if (!o.resolved) {
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
status: CheckStatus.Missing,
|
|
126
|
+
detail: o.error ? firstLine(o.error) : "@mailwoman/neural-weights-en-us is not resolvable",
|
|
127
|
+
fix: WEIGHTS_FIX,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!o.modelSize || !o.tokenizerSize) {
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
status: CheckStatus.Degraded,
|
|
135
|
+
detail: `resolved (${o.resolved.source}) but a weight file is empty — model.onnx ${formatBytes(o.modelSize ?? 0)}, tokenizer.model ${formatBytes(o.tokenizerSize ?? 0)}`,
|
|
136
|
+
fix: WEIGHTS_FIX,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...base,
|
|
142
|
+
status: CheckStatus.OK,
|
|
143
|
+
detail: `${o.resolved.source} · model.onnx ${formatBytes(o.modelSize)}, tokenizer.model ${formatBytes(o.tokenizerSize)}`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Facts about an optional locale-overlay weights package (e.g. fr-fr). */
|
|
148
|
+
export interface LocaleOverlayObservation {
|
|
149
|
+
locale: string
|
|
150
|
+
packageName: string
|
|
151
|
+
resolved: boolean
|
|
152
|
+
source?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Check #2 — a locale overlay (fr-fr). Informational (never core): its absence is expected on an en-us-only install. */
|
|
156
|
+
export function localeOverlayCheck(o: LocaleOverlayObservation): DoctorCheck {
|
|
157
|
+
const base = { id: `locale-overlay-${o.locale}`, label: `Locale overlay (${o.locale})`, core: false }
|
|
158
|
+
|
|
159
|
+
if (o.resolved) {
|
|
160
|
+
return { ...base, status: CheckStatus.OK, detail: `${o.packageName} resolvable${o.source ? ` (${o.source})` : ""}` }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
status: CheckStatus.Missing,
|
|
166
|
+
detail: `${o.packageName} not installed (optional — only needed for ${o.locale} parsing)`,
|
|
167
|
+
fix: `npm install ${o.packageName}`,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Facts about the resolved data root. */
|
|
172
|
+
export interface DataRootObservation {
|
|
173
|
+
/** The path from the blessed `@mailwoman/core/utils` helper — never re-derived here. */
|
|
174
|
+
path: string
|
|
175
|
+
exists: boolean
|
|
176
|
+
writable: boolean
|
|
177
|
+
/** Whether `$MAILWOMAN_DATA_ROOT` was set (vs. the built-in default). */
|
|
178
|
+
fromEnv: boolean
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Check #3 — the data root. Optional: an unwritable/absent root only blocks build tooling, not parse. */
|
|
182
|
+
export function dataRootCheck(o: DataRootObservation): DoctorCheck {
|
|
183
|
+
const base = { id: "data-root", label: "Data root", core: false }
|
|
184
|
+
const source = o.fromEnv ? "$MAILWOMAN_DATA_ROOT" : "default"
|
|
185
|
+
|
|
186
|
+
if (!o.exists) {
|
|
187
|
+
return {
|
|
188
|
+
...base,
|
|
189
|
+
status: CheckStatus.Missing,
|
|
190
|
+
detail: `${o.path} (${source}) does not exist`,
|
|
191
|
+
fix: `mkdir -p ${o.path} (or set $MAILWOMAN_DATA_ROOT to an existing dir)`,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!o.writable) {
|
|
196
|
+
return {
|
|
197
|
+
...base,
|
|
198
|
+
status: CheckStatus.Degraded,
|
|
199
|
+
detail: `${o.path} (${source}) exists but is not writable`,
|
|
200
|
+
fix: `chmod u+w ${o.path} (or set $MAILWOMAN_DATA_ROOT to a writable dir)`,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { ...base, status: CheckStatus.OK, detail: `${o.path} (${source}) — exists, writable` }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Facts about the admin gazetteer discovery, mirroring exactly what the TOOLS pick up. `mailwoman geocode` / `serve`
|
|
209
|
+
* resolve a candidate.db ONLY through `resolveCandidateDBPath` (explicit ?? `$MAILWOMAN_CANDIDATE_DB`) — there is no
|
|
210
|
+
* convention-path fallback — else they fall back to the WOF FTS shards. So a candidate.db sitting at the
|
|
211
|
+
* `<data-root>/wof/candidate.db` convention path while the env is UNSET is a TRAP: on disk, but the tools won't touch
|
|
212
|
+
* it.
|
|
213
|
+
*/
|
|
214
|
+
export interface GazetteerObservation {
|
|
215
|
+
/** A candidate.db the tools would actually use — explicit/`$MAILWOMAN_CANDIDATE_DB`, on disk. Green. */
|
|
216
|
+
envCandidate?: { path: string; sizeBytes?: number }
|
|
217
|
+
/** A WOF admin shard on disk — the FTS backend the tools fall back to when no env candidate is set. Green. */
|
|
218
|
+
wofShard?: { path: string; sizeBytes?: number }
|
|
219
|
+
/** A candidate.db at the convention path while `$MAILWOMAN_CANDIDATE_DB` is UNSET — the trap. Degraded, not green. */
|
|
220
|
+
conventionCandidate?: string
|
|
221
|
+
/** The paths probed, for the not-found detail. */
|
|
222
|
+
probed: string[]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const CANDIDATE_URL = "https://public.sister.software/mailwoman/gazetteer/2026-07-07a/candidate.db"
|
|
226
|
+
|
|
227
|
+
/** Check #4 — the admin gazetteer. Optional: parse runs without it; only geocode/resolve need it. */
|
|
228
|
+
export function gazetteerCheck(o: GazetteerObservation): DoctorCheck {
|
|
229
|
+
const base = { id: "gazetteer", label: "Admin gazetteer", core: false }
|
|
230
|
+
|
|
231
|
+
if (o.envCandidate) {
|
|
232
|
+
const size = o.envCandidate.sizeBytes ? ` (${formatBytes(o.envCandidate.sizeBytes)})` : ""
|
|
233
|
+
|
|
234
|
+
return { ...base, status: CheckStatus.OK, detail: `candidate.db · ${o.envCandidate.path}${size}` }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (o.wofShard) {
|
|
238
|
+
const size = o.wofShard.sizeBytes ? ` (${formatBytes(o.wofShard.sizeBytes)})` : ""
|
|
239
|
+
|
|
240
|
+
return { ...base, status: CheckStatus.OK, detail: `WOF admin shard · ${o.wofShard.path}${size}` }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The trap: candidate.db is on disk at the convention path, but the tools resolve candidate only via the env — so
|
|
244
|
+
// they'd report "no gazetteer data found" while doctor could naively show green. Report degraded, not ok.
|
|
245
|
+
if (o.conventionCandidate) {
|
|
246
|
+
return {
|
|
247
|
+
...base,
|
|
248
|
+
status: CheckStatus.Degraded,
|
|
249
|
+
detail: `candidate.db on disk (${o.conventionCandidate}) but $MAILWOMAN_CANDIDATE_DB unset — geocode/serve won't use it`,
|
|
250
|
+
fix: `export MAILWOMAN_CANDIDATE_DB=${o.conventionCandidate}`,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
status: CheckStatus.Missing,
|
|
257
|
+
detail: `no candidate.db or WOF shard found (probed ${o.probed.length} path${o.probed.length === 1 ? "" : "s"})`,
|
|
258
|
+
fix: `curl -fSL ${CANDIDATE_URL} -o <data-root>/wof/candidate.db (then: export MAILWOMAN_CANDIDATE_DB=<data-root>/wof/candidate.db)`,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Facts about the POI layer (mirrors `gazetteer build poi`'s default output path). */
|
|
263
|
+
export interface POIObservation {
|
|
264
|
+
path: string
|
|
265
|
+
exists: boolean
|
|
266
|
+
/** The parsed layer manifest, when the db opened and validated. */
|
|
267
|
+
manifest?: { name: string; version: string; sourceVintage: string }
|
|
268
|
+
/** A read error, when the db exists but the manifest couldn't be read. */
|
|
269
|
+
error?: string
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const POI_URL = "https://public.sister.software/mailwoman/poi/2026-07-20a/poi.db"
|
|
273
|
+
|
|
274
|
+
/** Check #5 — the POI layer. Optional: only POI-query execution needs it. */
|
|
275
|
+
export function checkPOI(o: POIObservation): DoctorCheck {
|
|
276
|
+
const base = { id: "poi-layer", label: "POI layer", core: false }
|
|
277
|
+
const fix = `mailwoman gazetteer build poi (or: curl -fSL ${POI_URL} -o ${o.path})`
|
|
278
|
+
|
|
279
|
+
if (!o.exists) {
|
|
280
|
+
return { ...base, status: CheckStatus.Missing, detail: `${o.path} not found`, fix }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!o.manifest) {
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
status: CheckStatus.Degraded,
|
|
287
|
+
detail: `${o.path} present but the layer manifest is unreadable${o.error ? `: ${firstLine(o.error)}` : ""}`,
|
|
288
|
+
fix,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
...base,
|
|
294
|
+
status: CheckStatus.OK,
|
|
295
|
+
detail: `${o.manifest.name} v${o.manifest.version} · vintage ${o.manifest.sourceVintage} · ${o.path}`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Facts about the Node runtime version vs. the package `engines` floor. */
|
|
300
|
+
export interface NodeRuntimeObservation {
|
|
301
|
+
nodeVersion: string
|
|
302
|
+
enginesFloor: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Check #6a — the Node version floor. CORE. */
|
|
306
|
+
export function nodeVersionCheck(o: NodeRuntimeObservation): DoctorCheck {
|
|
307
|
+
const base = { id: "node-version", label: "Node runtime", core: true }
|
|
308
|
+
|
|
309
|
+
if (versionMeetsFloor(o.nodeVersion, o.enginesFloor)) {
|
|
310
|
+
return { ...base, status: CheckStatus.OK, detail: `node v${o.nodeVersion} (engines: ${o.enginesFloor})` }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
...base,
|
|
315
|
+
status: CheckStatus.Degraded,
|
|
316
|
+
detail: `node v${o.nodeVersion} is below the required ${o.enginesFloor}`,
|
|
317
|
+
fix: `upgrade Node to satisfy ${o.enginesFloor}`,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Facts about the ONNX runtime binding. */
|
|
322
|
+
export interface OnnxRuntimeObservation {
|
|
323
|
+
loadable: boolean
|
|
324
|
+
error?: string
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Check #6b — onnxruntime-node loadability. CORE: the neural runtime cannot infer without it. */
|
|
328
|
+
export function onnxRuntimeCheck(o: OnnxRuntimeObservation): DoctorCheck {
|
|
329
|
+
const base = { id: "onnxruntime", label: "ONNX runtime", core: true }
|
|
330
|
+
|
|
331
|
+
if (o.loadable) {
|
|
332
|
+
return { ...base, status: CheckStatus.OK, detail: "onnxruntime-node loadable" }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
status: CheckStatus.Degraded,
|
|
338
|
+
detail: `onnxruntime-node failed to load${o.error ? `: ${firstLine(o.error)}` : ""}`,
|
|
339
|
+
fix: "npm install onnxruntime-node (or reinstall @mailwoman/neural)",
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Aggregate
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Derive the process exit code: `0` when every CORE check is `ok`, else `1`. Optional data-layer checks report their
|
|
349
|
+
* gaps but never fail the process — the meaning-of-zero rule (a missing optional layer is not a hard error).
|
|
350
|
+
*/
|
|
351
|
+
export function computeExitCode(checks: readonly DoctorCheck[]): number {
|
|
352
|
+
return checks.some((c) => c.core && c.status !== CheckStatus.OK) ? 1 : 0
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Assemble the report + exit code from the ordered checks. */
|
|
356
|
+
export function assembleReport(checks: DoctorCheck[]): DoctorReport {
|
|
357
|
+
return { checks, exitCode: computeExitCode(checks) }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** First line of a possibly-multiline error/stack, trimmed — keeps the checklist to one line per check. */
|
|
361
|
+
function firstLine(message: string): string {
|
|
362
|
+
return message.split("\n", 1)[0]!.trim()
|
|
363
|
+
}
|