@avantf/dsh-plugin-base 0.3.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.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/artifact.d.ts +29 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +60 -0
- package/dist/artifact.js.map +1 -0
- package/dist/bootstrap.d.ts +54 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +334 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +37 -0
- package/dist/cli.js.map +1 -0
- package/dist/compat.d.ts +413 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +742 -0
- package/dist/compat.js.map +1 -0
- package/dist/conformance.d.ts +55 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +702 -0
- package/dist/conformance.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs.d.ts +18 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +152 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +5 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +33 -0
- package/dist/integrity.js.map +1 -0
- package/dist/interface.d.ts +262 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +136 -0
- package/dist/interface.js.map +1 -0
- package/dist/interface_gate.d.ts +61 -0
- package/dist/interface_gate.d.ts.map +1 -0
- package/dist/interface_gate.js +103 -0
- package/dist/interface_gate.js.map +1 -0
- package/dist/internal.d.ts +23 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +18 -0
- package/dist/internal.js.map +1 -0
- package/dist/kit/family.d.ts +52 -0
- package/dist/kit/family.d.ts.map +1 -0
- package/dist/kit/family.js +65 -0
- package/dist/kit/family.js.map +1 -0
- package/dist/kit/index.d.ts +26 -0
- package/dist/kit/index.d.ts.map +1 -0
- package/dist/kit/index.js +26 -0
- package/dist/kit/index.js.map +1 -0
- package/dist/kit/logger.d.ts +41 -0
- package/dist/kit/logger.d.ts.map +1 -0
- package/dist/kit/logger.js +42 -0
- package/dist/kit/logger.js.map +1 -0
- package/dist/kit/prompt_files.d.ts +90 -0
- package/dist/kit/prompt_files.d.ts.map +1 -0
- package/dist/kit/prompt_files.js +121 -0
- package/dist/kit/prompt_files.js.map +1 -0
- package/dist/kit/typert.d.ts +40 -0
- package/dist/kit/typert.d.ts.map +1 -0
- package/dist/kit/typert.js +17 -0
- package/dist/kit/typert.js.map +1 -0
- package/dist/layout.d.ts +28 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +84 -0
- package/dist/layout.js.map +1 -0
- package/dist/lint.d.ts +33 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +271 -0
- package/dist/lint.js.map +1 -0
- package/dist/lock.d.ts +12 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +93 -0
- package/dist/lock.js.map +1 -0
- package/dist/manifest.d.ts +12 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +75 -0
- package/dist/manifest.js.map +1 -0
- package/dist/net.d.ts +23 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +112 -0
- package/dist/net.js.map +1 -0
- package/dist/package-name.d.ts +3 -0
- package/dist/package-name.d.ts.map +1 -0
- package/dist/package-name.js +13 -0
- package/dist/package-name.js.map +1 -0
- package/dist/preset.d.ts +46 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +230 -0
- package/dist/preset.js.map +1 -0
- package/dist/providers/archive.d.ts +32 -0
- package/dist/providers/archive.d.ts.map +1 -0
- package/dist/providers/archive.js +248 -0
- package/dist/providers/archive.js.map +1 -0
- package/dist/providers/model.d.ts +25 -0
- package/dist/providers/model.d.ts.map +1 -0
- package/dist/providers/model.js +550 -0
- package/dist/providers/model.js.map +1 -0
- package/dist/providers/npm.d.ts +21 -0
- package/dist/providers/npm.d.ts.map +1 -0
- package/dist/providers/npm.js +458 -0
- package/dist/providers/npm.js.map +1 -0
- package/dist/provisioner.d.ts +21 -0
- package/dist/provisioner.d.ts.map +1 -0
- package/dist/provisioner.js +962 -0
- package/dist/provisioner.js.map +1 -0
- package/dist/semver.d.ts +31 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +244 -0
- package/dist/semver.js.map +1 -0
- package/dist/state.d.ts +71 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +225 -0
- package/dist/state.js.map +1 -0
- package/dist/tar.d.ts +10 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +178 -0
- package/dist/tar.js.map +1 -0
- package/dist/types.d.ts +431 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zip.d.ts +8 -0
- package/dist/zip.d.ts.map +1 -0
- package/dist/zip.js +109 -0
- package/dist/zip.js.map +1 -0
- package/package.json +84 -0
package/dist/compat.js
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup compatibility gate for DSH plugins.
|
|
3
|
+
*
|
|
4
|
+
* A DSH plugin's whole API surface is DSH's, and dsh moves fast: a renamed method or a
|
|
5
|
+
* moved registry contract has no fallback. Without a gate the failure modes are the bad
|
|
6
|
+
* ones — a throw halfway through `apply` (half a mounted plugin), or a plugin that mounts
|
|
7
|
+
* and is silently inert (tools registered nowhere, a wake gate never entered).
|
|
8
|
+
*
|
|
9
|
+
* So the gate runs FIRST in `apply()` and answers one question: is the host API this build
|
|
10
|
+
* was written against actually here? Three rules, in the order they matter:
|
|
11
|
+
*
|
|
12
|
+
* - **proven break** (a required service or method is gone, or a registry refused or
|
|
13
|
+
* dropped a declaration shaped like the real one) → `load: false`: register nothing,
|
|
14
|
+
* log the diagnosis, and (optionally) keep one command that reports it to the user.
|
|
15
|
+
* - **risk signal** (the version this build's own links resolve to differs from the one it was
|
|
16
|
+
* compiled against, or a version cannot be resolved) → a warning, never a refusal. A version
|
|
17
|
+
* difference has never by itself made a plugin wrong, and refusing on one would take a
|
|
18
|
+
* working plugin out of a deployment that merely upgraded.
|
|
19
|
+
* - **undeterminable** (event names: nothing inside a plugin can prove the host still
|
|
20
|
+
* EMITS them) → recorded in the report, never blocking. "Cannot tell" is not
|
|
21
|
+
* "incompatible".
|
|
22
|
+
*
|
|
23
|
+
* The version comparison has a deliberate BOUNDARY: it covers this artifact against the dsh ITS
|
|
24
|
+
* LINKS point at, and never the identity of the process it is loaded into. A plugin cannot observe
|
|
25
|
+
* its host's version (no service exposes it), so a checkout host sharing an installed dsh with the
|
|
26
|
+
* plugin prints the INSTALLED version — a boundary, not a bug. See `README.md` «已知边界».
|
|
27
|
+
*
|
|
28
|
+
* The caller supplies a {@link CompatSpec} — what IT calls, which packages identify the
|
|
29
|
+
* host, which schemas and tools must be visible afterwards. The rules are a pure function
|
|
30
|
+
* of plain evidence ({@link verdictOf}), so they are unit-tested without a host.
|
|
31
|
+
*
|
|
32
|
+
* Nothing here throws. A probe that explodes is a FAILED probe, not a crash.
|
|
33
|
+
*
|
|
34
|
+
* @module @avantf/dsh-plugin-base
|
|
35
|
+
*/
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
/**
|
|
40
|
+
* Every line this module logs carries this token, so a support question can be answered
|
|
41
|
+
* with one grep of the host log.
|
|
42
|
+
*/
|
|
43
|
+
export const COMPAT_PREFIX = 'compat:';
|
|
44
|
+
/** A JSON object, as opposed to `null` / an array / a primitive. */
|
|
45
|
+
function isRecord(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
/** Best-effort one-line reason for a caught value. */
|
|
49
|
+
function reasonOf(error) {
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Turn one declared peer range into the version FLOOR it names.
|
|
54
|
+
*
|
|
55
|
+
* The `declared` side's FALLBACK, used only when a build left no baked version
|
|
56
|
+
* ({@link readBuildVersions}). Only the floor is read — a caret range's upper bound is a promise
|
|
57
|
+
* about the future, while the floor is the exact release the range was written against.
|
|
58
|
+
*
|
|
59
|
+
* Compound ranges are the everyday form (`>=4.4.3 <5`, `>=0.1.5-rc.2 <0.2.0`), so the FIRST
|
|
60
|
+
* comparator is read and its trailing bounds are ignored: spacing, a `<`/`<=` bound, or a `||`
|
|
61
|
+
* alternative must not turn a readable floor into `undefined`. A range whose first comparator is
|
|
62
|
+
* itself an upper bound (`<2.0.0`, `<=2.0.0`), a tag (`latest`), or a protocol (`workspace:`) names
|
|
63
|
+
* no floor and yields `undefined`, which reports as unverified rather than as a mismatch.
|
|
64
|
+
* @param range - the peer range as written in `package.json`.
|
|
65
|
+
* @returns the version, or `undefined` when it cannot be read out.
|
|
66
|
+
*/
|
|
67
|
+
export function floorOf(range) {
|
|
68
|
+
if (range === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
// `[\^~]`, `>=`, `>`, `=` and a bare version are all lower bounds; `<` and `<=` are deliberately
|
|
71
|
+
// absent from the operator set, so they fail the match instead of being read as a floor. The token
|
|
72
|
+
// stops at whitespace or at an operator, so `4.4.3<5` reads as `4.4.3` too.
|
|
73
|
+
const match = /^\s*(?:[\^~]|>=?|=)?\s*v?(\d[0-9A-Za-z.+-]*)/u.exec(range);
|
|
74
|
+
return match?.[1];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read the version FLOORS a caller declares for the packages that identify the host.
|
|
78
|
+
*
|
|
79
|
+
* Taken from the caller's own `package.json` peer ranges, so the value travels with the artifact (a
|
|
80
|
+
* plugin's manifest ships with it) and cannot drift away from the contract it declares. A floor is a
|
|
81
|
+
* LOWER BOUND, which is why this is the fallback and not the preferred source: it cannot say which
|
|
82
|
+
* release the artifact was actually compiled against. {@link readBuildVersions} reads that, and falls
|
|
83
|
+
* back to these floors only for packages its build did not bake.
|
|
84
|
+
* @param manifestUrl - the caller's `package.json` (`new URL('../package.json', import.meta.url)`).
|
|
85
|
+
* @param packages - the packages to read, usually `['@deepseek-ai/dsh-tools', ...]`.
|
|
86
|
+
* @returns one entry per package; `undefined` means the manifest declares nothing for it.
|
|
87
|
+
*/
|
|
88
|
+
export function readDeclaredVersions(manifestUrl, packages) {
|
|
89
|
+
let peers = {};
|
|
90
|
+
try {
|
|
91
|
+
const path = manifestUrl instanceof URL ? fileURLToPath(manifestUrl) : manifestUrl;
|
|
92
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
93
|
+
peers = parsed.peerDependencies ?? {};
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// An unreadable manifest is not a verdict: every version reads as "unknown", which never blocks.
|
|
97
|
+
}
|
|
98
|
+
const declared = {};
|
|
99
|
+
for (const name of packages)
|
|
100
|
+
declared[name] = floorOf(peers[name]);
|
|
101
|
+
return declared;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The file a build bakes its exact versions into, next to the built entry.
|
|
105
|
+
*
|
|
106
|
+
* Named once here so the producer (a repo's build script) and the consumer (its `provision.ts`)
|
|
107
|
+
* cannot disagree about where it lives. It is a plain JSON object of `package → version`.
|
|
108
|
+
*/
|
|
109
|
+
export const BUILD_VERSIONS_FILE = 'dsh-build.json';
|
|
110
|
+
/**
|
|
111
|
+
* Read the exact versions THIS BUILD was compiled against, out of the JSON its build emitted.
|
|
112
|
+
*
|
|
113
|
+
* The `declared` side's PREFERRED source. A peer range only records a floor — the lowest release the
|
|
114
|
+
* artifact tolerates — so after an in-place upgrade of the installed dsh it says nothing about the
|
|
115
|
+
* copy this build actually linked and compiled against, and the comparison degrades to a weak one.
|
|
116
|
+
* The build knows that copy exactly, so it bakes it next to the entry (`lib/dsh-build.json`) and this
|
|
117
|
+
* reads it back from `new URL('./dsh-build.json', import.meta.url)`.
|
|
118
|
+
*
|
|
119
|
+
* The file is OPTIONAL by design: a dev tree, a test run and a build made before this file existed
|
|
120
|
+
* all lack it, and none of them may crash or invent a version. The fallback is per PACKAGE rather
|
|
121
|
+
* than all-or-nothing, so a partially written file still reports honestly for the entries it has.
|
|
122
|
+
* @param buildUrl - where the build wrote the JSON (`new URL('./dsh-build.json', import.meta.url)`).
|
|
123
|
+
* @param packages - the packages to read, usually `['@deepseek-ai/dsh-tools', ...]`.
|
|
124
|
+
* @param fallback - per-package value for entries the file has none for (the peer floors).
|
|
125
|
+
* @returns one entry per package; `undefined` means neither source has a version.
|
|
126
|
+
*/
|
|
127
|
+
export function readBuildVersions(buildUrl, packages, fallback = {}) {
|
|
128
|
+
let baked = {};
|
|
129
|
+
try {
|
|
130
|
+
const path = buildUrl instanceof URL ? fileURLToPath(buildUrl) : buildUrl;
|
|
131
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
132
|
+
if (isRecord(parsed))
|
|
133
|
+
baked = parsed;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// No baked file (or one that is not a JSON object) is not a verdict: every package falls back.
|
|
137
|
+
}
|
|
138
|
+
const versions = {};
|
|
139
|
+
for (const name of packages) {
|
|
140
|
+
const value = baked[name];
|
|
141
|
+
versions[name] = typeof value === 'string' && value !== '' ? value : fallback[name];
|
|
142
|
+
}
|
|
143
|
+
return versions;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the version of a peer copy THIS BUILD's links point at.
|
|
147
|
+
*
|
|
148
|
+
* This is the `runtime` side: it answers "what do this artifact's own links resolve to", NOT "which
|
|
149
|
+
* host is running". The two coincide when the plugin shares the host's copy, and diverge whenever
|
|
150
|
+
* the links point elsewhere — a checkout host loading a plugin linked to an installed dsh is the
|
|
151
|
+
* everyday case. Nothing inside a plugin can observe the host's own version (no dsh service exposes
|
|
152
|
+
* it), so the log wording never claims to; see `README.md` «已知边界».
|
|
153
|
+
*
|
|
154
|
+
* A plugin cannot resolve `@deepseek-ai/dsh/package.json` (the core package is not in its
|
|
155
|
+
* `node_modules`), but the packages below ship in lockstep with it and ARE resolvable
|
|
156
|
+
* through the same links the rest of the plugin uses. Any failure is "unknown", never a
|
|
157
|
+
* throw.
|
|
158
|
+
* `base` decides WHOSE links are used. It defaults to this package, which is right when this package
|
|
159
|
+
* is the only consumer — but a plugin should pass its own `import.meta.url`: the packages that
|
|
160
|
+
* identify the host are ITS peers, and the shared package may not be able to see all of them.
|
|
161
|
+
* @param name - the peer package.
|
|
162
|
+
* @param base - module URL to resolve from (defaults to this package).
|
|
163
|
+
* @returns the version string, or `undefined`.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveRuntimeVersion(name, base = import.meta.url) {
|
|
166
|
+
try {
|
|
167
|
+
const require = createRequire(base);
|
|
168
|
+
// Built at runtime so a bundler cannot constant-fold it and resolve at build time.
|
|
169
|
+
const manifest = require.resolve(`${name}/package.json`);
|
|
170
|
+
const parsed = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
171
|
+
return typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : undefined;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Ask the live context for one entry per declared service.
|
|
179
|
+
*
|
|
180
|
+
* A service that is present but missing a method the caller declared is a BREAK, not a note: the
|
|
181
|
+
* caller's code will call it. A service that is absent is a problem only when it is required.
|
|
182
|
+
* @param ctx - the live context (structural).
|
|
183
|
+
* @param contracts - what the caller calls.
|
|
184
|
+
* @returns one probe per contract.
|
|
185
|
+
*/
|
|
186
|
+
export function checkServices(ctx, contracts) {
|
|
187
|
+
const services = [];
|
|
188
|
+
for (const contract of contracts) {
|
|
189
|
+
let service;
|
|
190
|
+
try {
|
|
191
|
+
service = ctx.get(contract.name);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
service = undefined;
|
|
195
|
+
}
|
|
196
|
+
const missing = service === undefined || service === null
|
|
197
|
+
? [...contract.methods]
|
|
198
|
+
: contract.methods.filter((method) => {
|
|
199
|
+
try {
|
|
200
|
+
return typeof service[method] !== 'function';
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// A service whose members throw on access exposes nothing usable.
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
services.push({ name: contract.name, required: contract.required, present: service !== undefined, missing });
|
|
208
|
+
}
|
|
209
|
+
return services;
|
|
210
|
+
}
|
|
211
|
+
/** Whether the timer plugin's effect-scoped timer is reachable as `ctx.interval`. */
|
|
212
|
+
export function checkInterval(ctx) {
|
|
213
|
+
try {
|
|
214
|
+
return typeof ctx['interval'] === 'function';
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The throwaway tool the probe registers: the same declaration shape `tools.ts` uses,
|
|
222
|
+
* including the `output` contract the model-facing render depends on.
|
|
223
|
+
*
|
|
224
|
+
* Withdrawn in `finally`, so a successful probe leaves the registry exactly as it found
|
|
225
|
+
* it (a failed withdrawal would show up as a tenth tool in the mount smoke).
|
|
226
|
+
*/
|
|
227
|
+
export const COMPAT_PROBE_TOOL = '__dshCompatProbe';
|
|
228
|
+
/**
|
|
229
|
+
* Build the tools-probe declaration from the CALLER's own declaration helper.
|
|
230
|
+
*
|
|
231
|
+
* The helper is injected rather than imported on purpose. This package is a base module: pinning a
|
|
232
|
+
* dsh version would make it stale with every dsh release, and a dsh-free source is what lets one copy
|
|
233
|
+
* serve every plugin. The caller also happens to be the right owner — the probe must mirror the
|
|
234
|
+
* declaration path the caller's REAL tools use, and only the caller has that helper.
|
|
235
|
+
* @param defineTool - the caller's `defineTool` (from its own `@deepseek-ai/dsh-tools`).
|
|
236
|
+
* @returns a zero-argument builder for `CompatSpec.probeTool`.
|
|
237
|
+
*/
|
|
238
|
+
export function toolProbeDeclaration(defineTool) {
|
|
239
|
+
return () => defineTool({
|
|
240
|
+
name: COMPAT_PROBE_TOOL,
|
|
241
|
+
description: 'compatibility probe (withdrawn immediately)',
|
|
242
|
+
parameters: { probe: { type: 'string', required: true, description: 'probe' } },
|
|
243
|
+
output: {
|
|
244
|
+
schema: { type: 'object', additionalProperties: true },
|
|
245
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
246
|
+
},
|
|
247
|
+
execute: () => Promise.resolve({ ok: true }),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Register the probe tool, check the registry kept it, and withdraw it.
|
|
252
|
+
*
|
|
253
|
+
* What this proves that a method-presence check cannot: the registry ACCEPTED our
|
|
254
|
+
* declaration shape and still lists it. It is the difference between "`register` is a
|
|
255
|
+
* function" and "registering what we register works".
|
|
256
|
+
* @param registry - `ctx.tools`.
|
|
257
|
+
* @returns the outcome; never throws.
|
|
258
|
+
*/
|
|
259
|
+
export function probeToolsRegistry(registry, buildProbe) {
|
|
260
|
+
// No builder ⇒ nothing to prove: "not probed" keeps the plugin loading, and the caller's spec is
|
|
261
|
+
// where that choice is visible.
|
|
262
|
+
if (buildProbe === undefined)
|
|
263
|
+
return { ran: false, passed: false, problems: [] };
|
|
264
|
+
if (!isRecord(registry) || typeof registry['register'] !== 'function' || typeof registry['get'] !== 'function') {
|
|
265
|
+
return { ran: false, passed: false, problems: [] };
|
|
266
|
+
}
|
|
267
|
+
const tools = registry;
|
|
268
|
+
const problems = [];
|
|
269
|
+
let registered = false;
|
|
270
|
+
let dispose;
|
|
271
|
+
try {
|
|
272
|
+
try {
|
|
273
|
+
dispose = tools.register(buildProbe());
|
|
274
|
+
registered = true;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
problems.push(`ctx.tools.register() refused a probe declaration: ${reasonOf(error)}`);
|
|
278
|
+
}
|
|
279
|
+
if (registered) {
|
|
280
|
+
try {
|
|
281
|
+
const found = tools.get(COMPAT_PROBE_TOOL);
|
|
282
|
+
const name = isRecord(found) ? found['name'] : undefined;
|
|
283
|
+
if (name !== COMPAT_PROBE_TOOL) {
|
|
284
|
+
problems.push(`the probe tool was not recorded: tools.get('${COMPAT_PROBE_TOOL}') answered ${found === undefined ? 'undefined' : `${typeof found} (name ${String(name)})`}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
problems.push(`tools.get() threw for the probe tool: ${reasonOf(error)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
if (typeof dispose === 'function') {
|
|
294
|
+
try {
|
|
295
|
+
const result = dispose();
|
|
296
|
+
if (isRecord(result) && 'then' in result)
|
|
297
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
problems.push('the probe tool could not be withdrawn (its disposer threw)');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { ran: true, passed: problems.length === 0, problems };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Register the caller's Typert declaration, verify the query surface, and withdraw it.
|
|
308
|
+
*
|
|
309
|
+
* The schema is a LIVE zod object, which is the point: `toJSONSchema` runs the HOST's
|
|
310
|
+
* projector over OUR schema, the cross-copy step that breaks when the codec contract
|
|
311
|
+
* moves (`TypertSchema{schema}` → `TypertSchemaFactory{create}` did exactly this once).
|
|
312
|
+
* What is verified — the schema names AND the package face — is read off the declaration, so
|
|
313
|
+
* handing over the real contribution makes a pass mean "the real registration will land too".
|
|
314
|
+
* @param registry - `ctx.typert`.
|
|
315
|
+
* @param packageId - this plugin's package id, which the probe key is built from.
|
|
316
|
+
* @returns the outcome; never throws.
|
|
317
|
+
*/
|
|
318
|
+
export function probeTypertRegistry(registry, packageId, buildProbe) {
|
|
319
|
+
// No declaration ⇒ nothing to prove. Reporting "not probed" keeps the plugin loading; inventing a
|
|
320
|
+
// shape here is what produced a false `ok` on a host whose codec contract had moved.
|
|
321
|
+
if (buildProbe === undefined)
|
|
322
|
+
return { ran: false, passed: false, problems: [] };
|
|
323
|
+
const required = ['register', 'get', 'list', 'listPackages', 'toJSONSchema'];
|
|
324
|
+
const host = isRecord(registry) ? registry : undefined;
|
|
325
|
+
if (host === undefined || required.some((method) => typeof host[method] !== 'function')) {
|
|
326
|
+
return { ran: false, passed: false, problems: [] };
|
|
327
|
+
}
|
|
328
|
+
// The verification follows what the DECLARATION declares — never a name this function assumes. The
|
|
329
|
+
// caller may hand over its real face (that is the recommended form), and a hardcoded schema name OR
|
|
330
|
+
// face would then report "not recorded" about a registration that worked. The registry accepts two
|
|
331
|
+
// faces (`host` and `client`), so the expected one is the declaration's, not this function's guess.
|
|
332
|
+
const declaration = buildProbe(packageId);
|
|
333
|
+
const expectedKeys = declaredSchemaKeys(packageId, schemaNamesFrom(declaration));
|
|
334
|
+
const declaredFace = isRecord(declaration) && typeof declaration['face'] === 'string'
|
|
335
|
+
? declaration['face']
|
|
336
|
+
: undefined;
|
|
337
|
+
const problems = [];
|
|
338
|
+
let registered = false;
|
|
339
|
+
let dispose;
|
|
340
|
+
try {
|
|
341
|
+
try {
|
|
342
|
+
dispose = host['register'](declaration);
|
|
343
|
+
registered = true;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
problems.push(`ctx.typert.register() refused a probe contribution: ${reasonOf(error)}`);
|
|
347
|
+
}
|
|
348
|
+
// The package face is verified for EVERY registered contribution, schema or not: a model-only
|
|
349
|
+
// face declares no schema key to read back, and its registration still has to be visible.
|
|
350
|
+
if (registered) {
|
|
351
|
+
try {
|
|
352
|
+
const packages = host['listPackages']({ package: packageId });
|
|
353
|
+
const listed = Array.isArray(packages)
|
|
354
|
+
&& packages.some((row) => isRecord(row)
|
|
355
|
+
&& row['package'] === packageId
|
|
356
|
+
&& (declaredFace === undefined || row['face'] === declaredFace));
|
|
357
|
+
if (!listed) {
|
|
358
|
+
problems.push(`listPackages({ package: '${packageId}' }) did not list the probe's ${declaredFace ?? 'registered'} face`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
problems.push(`listPackages() threw: ${reasonOf(error)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (registered && expectedKeys.length > 0) {
|
|
366
|
+
try {
|
|
367
|
+
const listed = host['list']({ package: packageId });
|
|
368
|
+
const keys = Array.isArray(listed)
|
|
369
|
+
? listed.map((row) => (isRecord(row) ? row['key'] : undefined)).filter((value) => typeof value === 'string')
|
|
370
|
+
: [];
|
|
371
|
+
const absent = expectedKeys.filter((key) => !keys.includes(key));
|
|
372
|
+
const first = expectedKeys[0];
|
|
373
|
+
const viaGet = host['get'](first);
|
|
374
|
+
if (absent.length > 0 || viaGet === undefined) {
|
|
375
|
+
problems.push(`declared schemas are missing after registration: [${absent.join(', ')}] (list keys = [${keys.join(', ')}])`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
problems.push(`the typert query surface threw for a declared key: ${reasonOf(error)}`);
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const key = expectedKeys[0];
|
|
383
|
+
const projected = host['toJSONSchema'](key);
|
|
384
|
+
if (!isRecord(projected)) {
|
|
385
|
+
problems.push(`toJSONSchema('${key}') answered ${projected === undefined ? 'undefined' : typeof projected} instead of a JSON Schema object`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
problems.push(`toJSONSchema() threw over our declared schema: ${reasonOf(error)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
if (typeof dispose === 'function') {
|
|
395
|
+
try {
|
|
396
|
+
const result = dispose();
|
|
397
|
+
if (isRecord(result) && 'then' in result)
|
|
398
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
problems.push('the probe contribution could not be withdrawn (its disposer threw)');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { ran: true, passed: problems.length === 0, problems };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Resolve several versions at once, from one caller's links.
|
|
409
|
+
*
|
|
410
|
+
* "From one caller's links" is the whole boundary: these are the versions that caller resolves, not
|
|
411
|
+
* the host's. Pass the caller's own `import.meta.url` so the answer covers its peers.
|
|
412
|
+
* @param packages - the packages to resolve.
|
|
413
|
+
* @param base - module URL to resolve from (defaults to this package).
|
|
414
|
+
* @returns one entry per package; `undefined` means it could not be resolved.
|
|
415
|
+
*/
|
|
416
|
+
export function readRuntimeVersions(packages, base = import.meta.url) {
|
|
417
|
+
const versions = {};
|
|
418
|
+
for (const name of packages)
|
|
419
|
+
versions[name] = resolveRuntimeVersion(name, base);
|
|
420
|
+
return versions;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Read a live context into the plain evidence the rules consume.
|
|
424
|
+
*
|
|
425
|
+
* The probes are throwaway declarations shaped like the real ones: "`register` is a function" and
|
|
426
|
+
* "registering what we register works" are different claims, and only the second one catches a
|
|
427
|
+
* moved contract.
|
|
428
|
+
* @param ctx - the live context (structural: only `get` is used).
|
|
429
|
+
* @param spec - what the caller declared about itself.
|
|
430
|
+
* @returns the evidence.
|
|
431
|
+
*/
|
|
432
|
+
export function gatherEvidence(ctx, spec) {
|
|
433
|
+
const versions = Object.entries(spec.declared).map(([name, declared]) => ({
|
|
434
|
+
package: name,
|
|
435
|
+
declared,
|
|
436
|
+
runtime: spec.runtime?.[name] ?? resolveRuntimeVersion(name),
|
|
437
|
+
}));
|
|
438
|
+
const typert = ctx.get('typert');
|
|
439
|
+
return {
|
|
440
|
+
services: checkServices(ctx, spec.services),
|
|
441
|
+
interval: checkInterval(ctx),
|
|
442
|
+
needsInterval: spec.needsInterval === true,
|
|
443
|
+
toolsProbe: probeToolsRegistry(ctx.get('tools'), spec.probeTool),
|
|
444
|
+
typertProbe: typert === undefined || spec.probeTypert === undefined
|
|
445
|
+
? undefined
|
|
446
|
+
: probeTypertRegistry(typert, spec.packageId, spec.probeTypert),
|
|
447
|
+
versions,
|
|
448
|
+
events: spec.events ?? [],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The rule set: plain evidence in, decision plus log lines out.
|
|
453
|
+
*
|
|
454
|
+
* Kept pure so every branch — including the ones a live host cannot be made to produce —
|
|
455
|
+
* is unit-tested without a Context.
|
|
456
|
+
*
|
|
457
|
+
* Logging policy, so a healthy mount stays one line: warnings and problems are always
|
|
458
|
+
* logged, and everything merely UNDETERMINABLE lands in `notes` — returned for tests and
|
|
459
|
+
* support, not printed. A note is not a finding; the plugin that cannot probe a registry
|
|
460
|
+
* still says so in the verdict the caller holds.
|
|
461
|
+
* @param evidence - what {@link gatherEvidence} observed.
|
|
462
|
+
* @returns the verdict.
|
|
463
|
+
*/
|
|
464
|
+
export function verdictOf(evidence) {
|
|
465
|
+
const problems = [];
|
|
466
|
+
const warnings = [];
|
|
467
|
+
const notes = [];
|
|
468
|
+
const lines = [];
|
|
469
|
+
for (const service of evidence.services) {
|
|
470
|
+
if (!service.present) {
|
|
471
|
+
if (service.required) {
|
|
472
|
+
problems.push(`required service "${service.name}" is not mounted, and this plugin calls ${service.missing.join(', ')} on it`);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
notes.push(`optional service "${service.name}" is not mounted; the engine degrades without it`);
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (service.missing.length === 0)
|
|
480
|
+
continue;
|
|
481
|
+
const detail = `"${service.name}" is mounted but exposes no ${service.missing.join(', ')}`;
|
|
482
|
+
if (service.required)
|
|
483
|
+
problems.push(`required service ${detail}`);
|
|
484
|
+
else
|
|
485
|
+
notes.push(`optional service ${detail}; the feature that needs it degrades`);
|
|
486
|
+
}
|
|
487
|
+
if (evidence.needsInterval && !evidence.interval) {
|
|
488
|
+
problems.push('ctx.interval() is not available (the injected timer plugin exposes no timer, and the sweep needs one)');
|
|
489
|
+
}
|
|
490
|
+
if (evidence.toolsProbe === undefined) {
|
|
491
|
+
notes.push('the tools registry was not probed');
|
|
492
|
+
}
|
|
493
|
+
else if (!evidence.toolsProbe.ran) {
|
|
494
|
+
notes.push('the tools registry exposes no register/get pair; the tool surface was not probed');
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
problems.push(...evidence.toolsProbe.problems);
|
|
498
|
+
}
|
|
499
|
+
if (evidence.typertProbe === undefined) {
|
|
500
|
+
notes.push('no typert registry mounted; the 任务 view reports that instead of reading the tree');
|
|
501
|
+
}
|
|
502
|
+
else if (!evidence.typertProbe.ran) {
|
|
503
|
+
notes.push('the typert registry exposes no query surface; the wire contract was not probed');
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
problems.push(...evidence.typertProbe.problems);
|
|
507
|
+
}
|
|
508
|
+
let versionDiffered = false;
|
|
509
|
+
let versionUnknown = false;
|
|
510
|
+
for (const version of evidence.versions) {
|
|
511
|
+
if (version.declared === undefined || version.runtime === undefined) {
|
|
512
|
+
// Neither half is evidence of anything: a version reads as `unknown` in the ok line, never as a
|
|
513
|
+
// warning — a warning here would train the reader to ignore warnings.
|
|
514
|
+
versionUnknown = true;
|
|
515
|
+
notes.push(version.declared === undefined
|
|
516
|
+
? `this build records no version for ${version.package} (neither a baked build version nor a peer floor), so its links could not be matched against it`
|
|
517
|
+
: `this build's links resolve no ${version.package} (this build was compiled against ${version.declared})`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (version.runtime !== version.declared) {
|
|
521
|
+
versionDiffered = true;
|
|
522
|
+
warnings.push(`this build was compiled against ${version.package} ${version.declared}, but its links now resolve to ${version.runtime} — rebuild against this machine's dsh (\`pnpm build:dsh\`)`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (evidence.events.length > 0) {
|
|
526
|
+
notes.push(`event names cannot be proven from inside: ${evidence.events.join(', ')}`);
|
|
527
|
+
}
|
|
528
|
+
const load = problems.length === 0;
|
|
529
|
+
for (const warning of warnings)
|
|
530
|
+
lines.push({ level: 'warn', message: `${COMPAT_PREFIX} WARNING — ${warning}` });
|
|
531
|
+
for (const problem of problems)
|
|
532
|
+
lines.push({ level: 'error', message: `${COMPAT_PREFIX} INCOMPATIBLE — ${problem}` });
|
|
533
|
+
if (load) {
|
|
534
|
+
const present = evidence.services.filter((service) => service.present).length;
|
|
535
|
+
const probed = [
|
|
536
|
+
evidence.toolsProbe?.ran === true ? 'tools' : undefined,
|
|
537
|
+
evidence.typertProbe?.ran === true ? 'typert' : undefined,
|
|
538
|
+
].filter((name) => name !== undefined);
|
|
539
|
+
// An unresolvable version reads as `unknown` here rather than as a warning line: that is where
|
|
540
|
+
// the memory plugin puts it too, and it is the only place a healthy boot still says so.
|
|
541
|
+
const versions = evidence.versions
|
|
542
|
+
.map((version) => version.runtime === undefined
|
|
543
|
+
? `${version.package} unknown${version.declared === undefined ? '' : ` (compiled against ${version.declared})`}`
|
|
544
|
+
: `${version.package} ${version.runtime}`)
|
|
545
|
+
.join(', ');
|
|
546
|
+
lines.push({
|
|
547
|
+
level: 'info',
|
|
548
|
+
// `dsh links:` — never `running`: these are the versions this artifact's OWN links resolve, and
|
|
549
|
+
// the host's identity is not observable from inside a plugin (see the module header).
|
|
550
|
+
message: `${COMPAT_PREFIX} ok — ${String(present)} service(s) present, probed ${probed.length === 0 ? 'no registry' : probed.join(' + ')}${versions === '' ? '' : `, dsh links: ${versions} (versions this build's own links resolve; the host identity is not observed)`}`,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
lines.push({
|
|
555
|
+
level: 'error',
|
|
556
|
+
message: `${COMPAT_PREFIX} REFUSING to load — ${String(problems.length)} proven incompatibility(ies). Rebuild against this machine's dsh (\`pnpm build:dsh\`) or install the @deepseek-ai/dsh this build declares.`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// `probe-failed` covers every PROVEN break — a service or method that is gone is the same finding
|
|
560
|
+
// as a registry that refused our declaration: the host API is not the one this build needs.
|
|
561
|
+
const skipped = evidence.toolsProbe?.ran !== true && evidence.typertProbe?.ran !== true;
|
|
562
|
+
// Precedence mirrors the family's gate: a proven break first, then the version signal, then "no
|
|
563
|
+
// probe ran", then an unverifiable version, then healthy.
|
|
564
|
+
const status = !load
|
|
565
|
+
? 'probe-failed'
|
|
566
|
+
: versionDiffered
|
|
567
|
+
? 'version-mismatch'
|
|
568
|
+
: skipped
|
|
569
|
+
? 'probe-skipped'
|
|
570
|
+
: versionUnknown ? 'version-unknown' : 'ok';
|
|
571
|
+
return { load, skipped, status, problems, warnings, notes, lines, reason: load ? '' : problems.join('; ') };
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Run the check against a live context and log every line.
|
|
575
|
+
*
|
|
576
|
+
* Call this FIRST in `apply()`: on a refusal nothing has been registered yet, so "do not
|
|
577
|
+
* load" costs nothing to unwind. Never throws — a check that cannot be performed leaves
|
|
578
|
+
* the plugin loading, because "cannot tell" is not "incompatible".
|
|
579
|
+
* @param ctx - the plugin's context.
|
|
580
|
+
* @param log - the plugin's logger.
|
|
581
|
+
* @returns the verdict `apply` branches on.
|
|
582
|
+
*/
|
|
583
|
+
export function provision(ctx, log, spec) {
|
|
584
|
+
let verdict;
|
|
585
|
+
try {
|
|
586
|
+
verdict = verdictOf(gatherEvidence(ctx, spec));
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
const line = {
|
|
590
|
+
level: 'warn',
|
|
591
|
+
message: `${COMPAT_PREFIX} WARNING — the compatibility check itself failed (${reasonOf(error)}); loading anyway`,
|
|
592
|
+
};
|
|
593
|
+
log.warn(line.message);
|
|
594
|
+
return {
|
|
595
|
+
load: true,
|
|
596
|
+
skipped: true,
|
|
597
|
+
status: 'probe-skipped',
|
|
598
|
+
problems: [],
|
|
599
|
+
warnings: [line.message],
|
|
600
|
+
notes: ['the check itself failed'],
|
|
601
|
+
lines: [line],
|
|
602
|
+
reason: '',
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
for (const line of verdict.lines)
|
|
606
|
+
log[line.level](line.message);
|
|
607
|
+
return verdict;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Build the standard refusal report, so every plugin says the same thing in its own words.
|
|
611
|
+
*
|
|
612
|
+
* The STRUCTURE belongs here (heading, problems, warnings, the fix, where the full log is); the
|
|
613
|
+
* wording belongs to the caller, which passes every string in its own language. That includes the
|
|
614
|
+
* last line — this used to be a hardcoded Chinese sentence, which meant a caller that had translated
|
|
615
|
+
* everything else still got half a sentence in a language it never chose.
|
|
616
|
+
*
|
|
617
|
+
* `logPointer` is OPTIONAL because base is a published package: a plugin compiled against an earlier
|
|
618
|
+
* base does not pass it, and the honest result for that caller is no tail line at all rather than one
|
|
619
|
+
* in the wrong language.
|
|
620
|
+
* @param verdict - the refusal to report.
|
|
621
|
+
* @param words - the caller's copy.
|
|
622
|
+
* @param words.logPointer - the caller's sentence for "the full diagnostics are the log lines that
|
|
623
|
+
* start with `<prefix>`"; receive the prefix so the sentence can place it naturally.
|
|
624
|
+
* @returns the report text.
|
|
625
|
+
*/
|
|
626
|
+
export function compatReport(verdict, words) {
|
|
627
|
+
return [
|
|
628
|
+
words.heading,
|
|
629
|
+
'',
|
|
630
|
+
...verdict.problems.map((problem) => `- ${problem}`),
|
|
631
|
+
'',
|
|
632
|
+
...(verdict.warnings.length === 0 ? [] : [words.warningsLabel, ...verdict.warnings.map((warning) => `- ${warning}`), '']),
|
|
633
|
+
words.fix,
|
|
634
|
+
...(words.logPointer === undefined ? [] : [words.logPointer(COMPAT_PREFIX)]),
|
|
635
|
+
].join('\n');
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Keep one megaphone when a plugin refuses to load.
|
|
639
|
+
*
|
|
640
|
+
* Registering a command in a broken host is a judgment call: the plugin provides nothing else, and
|
|
641
|
+
* the alternative is a user watching a surface that says "not available" with no way to ask why. A
|
|
642
|
+
* host too broken to register one leaves the log as the only channel — which must not throw either.
|
|
643
|
+
* @param input - the context, the logger, the command to register, and the text it should print.
|
|
644
|
+
*/
|
|
645
|
+
export function registerMegaphone(input) {
|
|
646
|
+
try {
|
|
647
|
+
const commands = input.ctx.get('commands');
|
|
648
|
+
if (commands === undefined)
|
|
649
|
+
throw new Error('the commands service is not mounted');
|
|
650
|
+
commands.register({
|
|
651
|
+
name: input.command.name,
|
|
652
|
+
description: input.command.description,
|
|
653
|
+
handler: () => Promise.resolve({ kind: 'error', text: input.text }),
|
|
654
|
+
});
|
|
655
|
+
input.log.warn(`${COMPAT_PREFIX} registered a /${input.command.name} command that reports this refusal`);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
input.log.warn(`${COMPAT_PREFIX} could not register the reporting command (${reasonOf(error)}); the log is the only report`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* The schema names a Typert contribution declares, read from the contribution itself.
|
|
663
|
+
*
|
|
664
|
+
* Derived, never hardcoded: a schema added to a wire face must widen the post-registration check on
|
|
665
|
+
* its own.
|
|
666
|
+
* @param contribution - the object passed to `register` (`{ schemas: [{ name }] }`).
|
|
667
|
+
* @returns the names, in declaration order.
|
|
668
|
+
*/
|
|
669
|
+
export function schemaNamesFrom(contribution) {
|
|
670
|
+
const schemas = (isRecord(contribution) ? contribution['schemas'] : undefined);
|
|
671
|
+
if (!Array.isArray(schemas))
|
|
672
|
+
return [];
|
|
673
|
+
return schemas
|
|
674
|
+
.map((row) => (isRecord(row) && typeof row['name'] === 'string' ? row['name'] : undefined))
|
|
675
|
+
.filter((name) => name !== undefined);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The registry keys those names become: `<packageId>#<name>`.
|
|
679
|
+
* @param packageId - the contributing package.
|
|
680
|
+
* @param names - the schema names.
|
|
681
|
+
* @returns the keys the registry is expected to list.
|
|
682
|
+
*/
|
|
683
|
+
export function declaredSchemaKeys(packageId, names) {
|
|
684
|
+
return names.map((name) => `${packageId}#${name}`);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The SECOND phase: after the real faces registered, confirm every one of them is visible.
|
|
688
|
+
*
|
|
689
|
+
* The pre-load probe proves the declaration SHAPE is accepted; it cannot prove that all of the real
|
|
690
|
+
* registrations landed. A silently dropped schema or tool fails exactly the way this whole module
|
|
691
|
+
* exists to prevent — the panel renders nothing, or the model never sees a tool — with no error
|
|
692
|
+
* anywhere. So the keys are counted by EXACT key after registration (never by a bare total, which
|
|
693
|
+
* an un-withdrawn probe would inflate) and every tool name is read back.
|
|
694
|
+
*
|
|
695
|
+
* Warn-only, for the reason the memory plugin records for its own post-registration count: by now
|
|
696
|
+
* the plugin is mounted, so there is no clean "do not load" left to take. Silent when everything is
|
|
697
|
+
* there — a healthy boot keeps its single `compat: ok` line.
|
|
698
|
+
*
|
|
699
|
+
* Known limit: invocation ENDPOINTS are not observable through the documented query surface
|
|
700
|
+
* (`list`/`listPackages` expose schemas and the package model only), so the four descriptors are
|
|
701
|
+
* covered through the schema keys they use, not counted themselves.
|
|
702
|
+
* @param input - the live context, the real tool names, and the logger.
|
|
703
|
+
* @returns the registrations that did not show up (empty on success).
|
|
704
|
+
*/
|
|
705
|
+
export function verifyRegisteredFaces(input) {
|
|
706
|
+
const expectedKeys = declaredSchemaKeys(input.packageId, input.schemaNames ?? []);
|
|
707
|
+
const missing = [];
|
|
708
|
+
const typert = input.ctx.get('typert');
|
|
709
|
+
if (isRecord(typert) && typeof typert['list'] === 'function') {
|
|
710
|
+
try {
|
|
711
|
+
const rows = typert['list']({ package: input.packageId });
|
|
712
|
+
const listed = new Set((Array.isArray(rows) ? rows : [])
|
|
713
|
+
.map((row) => (isRecord(row) ? row['key'] : undefined))
|
|
714
|
+
.filter((key) => typeof key === 'string'));
|
|
715
|
+
for (const key of expectedKeys)
|
|
716
|
+
if (!listed.has(key))
|
|
717
|
+
missing.push(key);
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
input.log.warn(`${COMPAT_PREFIX} WARNING — the post-registration schema check could not run (${reasonOf(error)})`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const tools = input.ctx.get('tools');
|
|
724
|
+
if (isRecord(tools) && typeof tools['get'] === 'function') {
|
|
725
|
+
for (const name of input.toolNames) {
|
|
726
|
+
try {
|
|
727
|
+
if (tools['get'](name) === undefined)
|
|
728
|
+
missing.push(name);
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
missing.push(name);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (missing.length > 0) {
|
|
736
|
+
input.log.warn(`${COMPAT_PREFIX} WARNING — ${String(missing.length)} of ${String(expectedKeys.length + input.toolNames.length)} `
|
|
737
|
+
+ `real registrations are not visible afterwards: ${missing.join(', ')} — the plugin is already mounted, so this is `
|
|
738
|
+
+ 'reported rather than undone; rebuild against this machine\'s dsh (`pnpm build:dsh`), restart `dsh`, and check again');
|
|
739
|
+
}
|
|
740
|
+
return { missing };
|
|
741
|
+
}
|
|
742
|
+
//# sourceMappingURL=compat.js.map
|