@jxsuite/server 4.5.0 → 4.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.
package/src/packages.ts CHANGED
@@ -8,42 +8,23 @@
8
8
  import { existsSync } from "node:fs";
9
9
  import { readFile, writeFile } from "node:fs/promises";
10
10
  import { resolve } from "node:path";
11
-
12
- export interface PackageInfo {
13
- name: string;
14
- version: string;
15
- /** True when the dependency lives in `devDependencies` rather than `dependencies`. */
16
- dev?: boolean;
17
- }
18
-
19
- /**
20
- * A dependency's pinned range beside the newest version published for it on the registry.
21
- *
22
- * Every registry-range dependency gets a row, behind or not. Deciding whether a row is an upgrade
23
- * needs a semver comparison against the range's base, and the caller — which has to make that
24
- * comparison anyway to tell an upgrade from a project deliberately pinned ahead — is where it
25
- * belongs. A backend that filtered here could only describe an up-to-date package as an absence.
26
- */
27
- export interface PackageVersionInfo {
28
- name: string;
29
- /** The version range pinned in package.json (e.g. "^0.19.0"). */
30
- current: string;
31
- /** The newest version published for this package on the registry. */
32
- latest: string;
33
- dev?: boolean;
34
- }
35
-
36
- /** Result of a package mutation that runs `bun install` (install / set-versions). */
37
- export interface PackageOpResult {
38
- ok: boolean;
39
- /** Combined stdout/stderr from the bun invocation, surfaced to the user on failure. */
40
- log?: string;
41
- }
42
-
11
+ import type { PackageInfo, PackageOpResult, PackageVersionInfo } from "@jxsuite/protocol";
12
+ import type { JsonValue } from "@jxsuite/schema/types";
13
+
14
+ /* The package rows and operation result are the Studio wire contract (`packages*` routes), declared
15
+ once in @jxsuite/protocol; re-exported so this module's public names stay. Every
16
+ registry-range dependency gets a `PackageVersionInfo` row, behind or not: deciding whether a row
17
+ is an upgrade needs a semver comparison against the range's base, and the caller, which has to
18
+ make that comparison anyway to tell an upgrade from a project deliberately pinned ahead, is where
19
+ it belongs. A backend that filtered here could only describe an up-to-date package as an
20
+ absence. */
21
+ export type { PackageInfo, PackageOpResult, PackageVersionInfo } from "@jxsuite/protocol";
22
+
23
+ /** The manifest members this module edits; every other member is written back unchanged. */
43
24
  interface PackageJson {
44
25
  dependencies?: Record<string, string>;
45
26
  devDependencies?: Record<string, string>;
46
- [key: string]: unknown;
27
+ [key: string]: JsonValue | undefined;
47
28
  }
48
29
 
49
30
  /** Minimal fetch signature (injectable for tests; the global `fetch` satisfies it). */
@@ -72,6 +53,9 @@ async function readPackageJson(root: string): Promise<PackageJson | null> {
72
53
  return null;
73
54
  }
74
55
  try {
56
+ /* SAFETY: package.json is the npm manifest `bun install` reads, an object whose
57
+ `dependencies`/`devDependencies` map package names to version-spec strings; text that is
58
+ not JSON throws into the catch below. */
75
59
  return JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
76
60
  } catch {
77
61
  return null;
@@ -120,7 +104,11 @@ async function runBun(args: string[], cwd: string): Promise<PackageOpResult> {
120
104
  proc.exited,
121
105
  ]);
122
106
  const log = `${stdout}${stderr}`.trim();
123
- return { ok: exitCode === 0, ...(log ? { log } : {}) };
107
+ const result: PackageOpResult = { ok: exitCode === 0 };
108
+ if (log) {
109
+ result.log = log;
110
+ }
111
+ return result;
124
112
  }
125
113
 
126
114
  /** Run `bun install` in the project root. */
@@ -165,6 +153,8 @@ export async function fetchLatestVersion(
165
153
  if (!res.ok) {
166
154
  return null;
167
155
  }
156
+ /* SAFETY: the registry's version endpoint answers that version's manifest, whose `version`
157
+ is a string; a body that is not JSON (or is `null`) throws into the catch, giving null. */
168
158
  const data = (await res.json()) as { version?: string };
169
159
  return data.version ?? null;
170
160
  } catch {
@@ -228,6 +218,7 @@ export async function setPackageVersions(
228
218
  }
229
219
  let pkg: PackageJson;
230
220
  try {
221
+ // SAFETY: same npm manifest contract as readPackageJson; unparseable text is caught below.
231
222
  pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
232
223
  } catch (error) {
233
224
  return { log: `Unparseable package.json: ${String(error)}`, ok: false };
@@ -239,8 +230,7 @@ export async function setPackageVersions(
239
230
  pkg.dependencies[update.name] = update.version;
240
231
  } else {
241
232
  const bucket = update.dev ? "devDependencies" : "dependencies";
242
- pkg[bucket] = { ...(pkg[bucket] as Record<string, string> | undefined) };
243
- (pkg[bucket] as Record<string, string>)[update.name] = update.version;
233
+ pkg[bucket] = { ...pkg[bucket], [update.name]: update.version };
244
234
  }
245
235
  }
246
236
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
package/src/problem.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { PROBLEM_MEDIA_TYPE, problemDetails } from "@jxsuite/protocol";
15
15
  import type { ProblemTypeName } from "@jxsuite/protocol";
16
+ import type { JsonObject } from "@jxsuite/schema/types";
16
17
 
17
18
  /**
18
19
  * A failure response for one occurrence of a declared problem type.
@@ -23,14 +24,10 @@ import type { ProblemTypeName } from "@jxsuite/protocol";
23
24
  *
24
25
  * @param {ProblemTypeName} name - A declared type from `PROBLEM_TYPES`
25
26
  * @param {string} [detail] - What happened this time; the line a human reads
26
- * @param {Record<string, unknown>} [extensions] - Extension members this type documents
27
+ * @param {JsonObject} [extensions] - Extension members this type documents
27
28
  * @returns {Response}
28
29
  */
29
- export function problem(
30
- name: ProblemTypeName,
31
- detail?: string,
32
- extensions?: Record<string, unknown>,
33
- ): Response {
30
+ export function problem(name: ProblemTypeName, detail?: string, extensions?: JsonObject): Response {
34
31
  const body = problemDetails(name, detail, extensions);
35
32
  // The explicit Content-Type is the point: `Response.json` would send `application/json`, and
36
33
  // RFC 9457 §3 is what tells a client this body is a problem rather than a result.
@@ -43,11 +43,36 @@ import {
43
43
  import { problem } from "./problem.ts";
44
44
  import { createLoopbackAuthorizer, OAUTH_CALLBACK_PATH } from "./oauth-loopback.ts";
45
45
  import type { LoopbackAuthorizer } from "./oauth-loopback.ts";
46
+ import type { FsEventBatch } from "./sse.ts";
47
+ import type { JsonPrimitive, JsonValue } from "@jxsuite/schema/types";
48
+
49
+ /**
50
+ * What a handler answers, sent as the reply frame's `result`: anything `JSON.stringify` encodes,
51
+ * with `undefined` sent as `null`. Objects are admitted by kind rather than by JSON shape because
52
+ * handlers answer with typed values, and TypeScript never treats an interface as a `JsonValue`.
53
+ */
54
+ export type RpcResult = JsonPrimitive | object | undefined;
55
+
56
+ /** One RPC method: the request frame's decoded `params` in, the reply's `result` out. */
57
+ export type RpcHandler = (params: JsonValue | undefined) => Promise<RpcResult>;
58
+
59
+ /**
60
+ * What an unsolicited push carries as its `params`: JSON, or the filesystem batch the sidebar
61
+ * consumes (the same batch the dev server sends as its SSE `fs` event).
62
+ */
63
+ export type RpcPushParams = JsonValue | FsEventBatch;
64
+
65
+ /** An RPC request frame as the shell sends it: the id the reply echoes, and what to dispatch. */
66
+ export interface RpcRequestFrame {
67
+ id: number;
68
+ method: string;
69
+ params?: JsonValue;
70
+ }
46
71
 
47
72
  /** A resolved per-window session: its project root plus its RPC handler map. */
48
73
  export interface ProjectServerSession {
49
74
  projectRoot: string | null;
50
- handlers: Record<string, (params: unknown) => Promise<unknown>>;
75
+ handlers: Record<string, RpcHandler>;
51
76
  }
52
77
 
53
78
  export interface CreateProjectServerOptions {
@@ -89,7 +114,7 @@ export interface ProjectServerHandle {
89
114
  * @param winId Deliver only to sockets stashed with this window id; omit for every socket.
90
115
  * @returns How many sockets the message reached — 0 means nothing is listening yet.
91
116
  */
92
- push: (method: string, params?: unknown, winId?: string | null) => number;
117
+ push: (method: string, params?: RpcPushParams, winId?: string | null) => number;
93
118
  /**
94
119
  * The RFC 8252 loopback authorization host. The desktop launcher drives it: `begin()` for the URL
95
120
  * to open in the user's browser, then `await pending.code` and {@link exchangeCode}.
@@ -337,9 +362,14 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
337
362
  sockets.delete(ws);
338
363
  },
339
364
  async message(ws, raw) {
340
- let msg: { id: number; method: string; params?: unknown };
365
+ let msg: RpcRequestFrame;
341
366
  try {
342
- msg = JSON.parse(raw as string) as { id: number; method: string; params?: unknown };
367
+ /* SAFETY: trust boundary. A binary frame decodes as UTF-8, exactly as JSON.parse's own
368
+ string coercion would, and the parse yields a JSON value. The only client on this
369
+ token-gated socket is the shell's RPC transport, which sends `{ id, method, params }`;
370
+ a frame that breaks that finds no handler under its `method` and is answered with an
371
+ error (a `null` frame throws in this callback, as it always has). */
372
+ msg = JSON.parse(raw.toString()) as RpcRequestFrame;
343
373
  } catch {
344
374
  ws.send(JSON.stringify({ error: "Invalid JSON", id: 0 }));
345
375
  return;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { basename, dirname, extname, relative, resolve } from "node:path";
12
12
  import { stat, writeFile } from "node:fs/promises";
13
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
13
14
  import { errorMessage } from "@jxsuite/schema/parse";
14
15
  import { isUnder } from "./paths.ts";
15
16
  import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
@@ -99,9 +100,9 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
99
100
  if (!isDir) {
100
101
  try {
101
102
  const { doc } = await loadDoc(absTo, opts.registry);
102
- const oldTag = (doc as { tagName?: unknown }).tagName;
103
+ const oldTag = isJsonObject(doc) ? doc.tagName : undefined;
103
104
  const newTag = deriveTag(absTo);
104
- if (typeof oldTag === "string" && oldTag.includes("-") && newTag !== oldTag) {
105
+ if (isString(oldTag) && oldTag.includes("-") && newTag !== oldTag) {
105
106
  if (newTag.includes("-")) {
106
107
  tagRename = { from: oldTag, to: newTag };
107
108
  } else {
Binary file
@@ -5,26 +5,25 @@
5
5
  */
6
6
 
7
7
  import { relative } from "node:path";
8
+ import type { FsEvent } from "@jxsuite/protocol";
8
9
 
9
- /** A filesystem change as broadcast to the studio shell (root-relative, forward-slashed path). */
10
- export interface FsEventPayload {
11
- type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
12
- path: string;
13
- isDir: boolean;
14
- }
10
+ /* A filesystem change as broadcast to the studio shell is the protocol's `FsEvent`, declared once
11
+ there; `FsEventPayload` stays this module's public name for it. */
12
+ export type { FsEvent as FsEventPayload } from "@jxsuite/protocol";
13
+
14
+ const VALID: ReadonlySet<string> = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
15
15
 
16
- const VALID = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
16
+ /** Whether chokidar's `eventType` is one of the five changes the sidebar is told about. */
17
+ function isFsEventType(eventType: string): eventType is FsEvent["type"] {
18
+ return VALID.has(eventType);
19
+ }
17
20
 
18
21
  /**
19
22
  * Map one chokidar event to a payload, or null for events we ignore (`ready`/`raw`/`error`) or
20
23
  * paths that escape the root.
21
24
  */
22
- export function toFsEvent(
23
- eventType: string,
24
- root: string,
25
- changedPath: string,
26
- ): FsEventPayload | null {
27
- if (!VALID.has(eventType)) {
25
+ export function toFsEvent(eventType: string, root: string, changedPath: string): FsEvent | null {
26
+ if (!isFsEventType(eventType)) {
28
27
  return null;
29
28
  }
30
29
  const path = relative(root, changedPath).replaceAll("\\", "/");
@@ -34,7 +33,7 @@ export function toFsEvent(
34
33
  return {
35
34
  isDir: eventType === "addDir" || eventType === "unlinkDir",
36
35
  path,
37
- type: eventType as FsEventPayload["type"],
36
+ type: eventType,
38
37
  };
39
38
  }
40
39
 
@@ -42,7 +41,7 @@ export function toFsEvent(
42
41
  * Fold one event into a buffer, collapsing redundant churn per path (last-write-wins, except
43
42
  * add+unlink cancels, add+change stays add, and unlink+add becomes a re-add). Returns a new array.
44
43
  */
45
- export function coalesceFsEvents(buffer: FsEventPayload[], next: FsEventPayload): FsEventPayload[] {
44
+ export function coalesceFsEvents(buffer: FsEvent[], next: FsEvent): FsEvent[] {
46
45
  const prev = buffer.find((e) => e.path === next.path);
47
46
  if (!prev) {
48
47
  return [...buffer, next];
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { BUILD_LANES, projectPathsForSiteUrl, siteUrlForPath } from "@jxsuite/schema/asset-paths";
21
21
  import type { AssetLane, AssetMount } from "@jxsuite/schema/asset-paths";
22
+ import { isString } from "@jxsuite/schema/guards";
23
+ import type { JsonValue } from "@jxsuite/schema/types";
22
24
 
23
25
  /**
24
26
  * The lanes a rewritten site URL is re-emitted through, most-publishable first.
@@ -43,6 +45,12 @@ const AUTHORED_LANES: readonly AssetLane[] = [...BUILD_LANES, "root"];
43
45
  */
44
46
  const READ_LANES: readonly AssetLane[] = ["mounts", "root", "public"];
45
47
 
48
+ /** A path-like value split at its first `?` or `#`: the path itself, and what trails it. */
49
+ export interface RefParts {
50
+ core: string;
51
+ suffix: string;
52
+ }
53
+
46
54
  /** Classification of a reference string value. */
47
55
  export type RefClass =
48
56
  | { kind: "none" }
@@ -51,7 +59,7 @@ export type RefClass =
51
59
  | { kind: "path"; core: string; suffix: string; rooted: boolean };
52
60
 
53
61
  /** Split a trailing `?query` or `#fragment` off a path-like value. */
54
- export function splitQueryHash(value: string): { core: string; suffix: string } {
62
+ export function splitQueryHash(value: string): RefParts {
55
63
  for (let i = 0; i < value.length; i += 1) {
56
64
  const ch = value[i];
57
65
  if (ch === "?" || ch === "#") {
@@ -68,8 +76,8 @@ export function splitQueryHash(value: string): { core: string; suffix: string }
68
76
  * actual precision, so bare npm specifiers (which never resolve to the renamed file) are simply
69
77
  * never matched.
70
78
  */
71
- export function classifyRef(value: unknown): RefClass {
72
- if (typeof value !== "string" || value.length === 0) {
79
+ export function classifyRef(value: JsonValue | undefined): RefClass {
80
+ if (!isString(value) || value.length === 0) {
73
81
  return { kind: "none" };
74
82
  }
75
83
  if (
@@ -26,6 +26,8 @@
26
26
  * @docs studio/projects/pages-layouts-components
27
27
  */
28
28
 
29
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
30
+ import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
29
31
  import { classifyRef, looksLikeFileRef, rewriteRef } from "./paths.ts";
30
32
  import type { RemapCtx } from "./paths.ts";
31
33
 
@@ -37,10 +39,16 @@ export interface RefChange {
37
39
  }
38
40
 
39
41
  export interface RewriteResult {
40
- doc: unknown;
42
+ doc: JsonValue;
41
43
  changes: RefChange[];
42
44
  }
43
45
 
46
+ /** The outcome of {@link rewriteTagName}: the same document, and how many nodes were renamed. */
47
+ export interface TagRewriteResult {
48
+ doc: JsonValue;
49
+ count: number;
50
+ }
51
+
44
52
  /**
45
53
  * One visit of a reference-bearing string.
46
54
  *
@@ -74,14 +82,14 @@ const PROSE_KEYS = new Set(["textContent", "innerHTML"]);
74
82
 
75
83
  /** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
76
84
  function visitScalar(
77
- container: Record<string, unknown>,
85
+ container: JsonObject,
78
86
  key: string,
79
87
  refType: string,
80
88
  visit: RefVisitor,
81
89
  rootRelativeBare: boolean,
82
90
  ): void {
83
91
  const value = container[key];
84
- if (typeof value !== "string") {
92
+ if (!isString(value)) {
85
93
  return;
86
94
  }
87
95
  const out = visit(value, refType, rootRelativeBare);
@@ -104,13 +112,13 @@ function visitScalar(
104
112
  * would.
105
113
  */
106
114
  function visitKeys(
107
- container: Record<string, unknown>,
115
+ container: JsonObject,
108
116
  refType: string,
109
117
  visit: RefVisitor,
110
118
  rootRelativeBare: boolean,
111
119
  ): void {
112
120
  const entries = Object.entries(container);
113
- const renamed = entries.map(([key, value]): [string, unknown] => {
121
+ const renamed = entries.map(([key, value]): [string, JsonValue] => {
114
122
  const out = visit(key, refType, rootRelativeBare);
115
123
  return [out === null ? key : out, value];
116
124
  });
@@ -126,9 +134,9 @@ function visitKeys(
126
134
  }
127
135
 
128
136
  /** Offer a string array member (e.g. an `$elements` entry) to the visitor. */
129
- function visitIndexed(arr: unknown[], index: number, refType: string, visit: RefVisitor): void {
137
+ function visitIndexed(arr: JsonValue[], index: number, refType: string, visit: RefVisitor): void {
130
138
  const value = arr[index];
131
- if (typeof value !== "string") {
139
+ if (!isString(value)) {
132
140
  return;
133
141
  }
134
142
  const out = visit(value, refType, false);
@@ -146,15 +154,14 @@ function visitUrls(value: string, visit: RefVisitor): string {
146
154
  }
147
155
 
148
156
  /** Walk a `style` value tree, visiting `url(...)` in every string it contains. */
149
- function walkStyle(node: unknown, visit: RefVisitor): void {
157
+ function walkStyle(node: JsonValue | undefined, visit: RefVisitor): void {
150
158
  if (Array.isArray(node)) {
151
- const arr = node as unknown[];
152
- for (let i = 0; i < arr.length; i += 1) {
153
- const item = arr[i];
154
- if (typeof item === "string") {
159
+ for (let i = 0; i < node.length; i += 1) {
160
+ const item = node[i];
161
+ if (isString(item)) {
155
162
  const out = visitUrls(item, visit);
156
163
  if (out !== item) {
157
- arr[i] = out;
164
+ node[i] = out;
158
165
  }
159
166
  } else {
160
167
  walkStyle(item, visit);
@@ -162,16 +169,15 @@ function walkStyle(node: unknown, visit: RefVisitor): void {
162
169
  }
163
170
  return;
164
171
  }
165
- if (!node || typeof node !== "object") {
172
+ if (!isJsonObject(node)) {
166
173
  return;
167
174
  }
168
- const obj = node as Record<string, unknown>;
169
- for (const key of Object.keys(obj)) {
170
- const value = obj[key];
171
- if (typeof value === "string") {
175
+ for (const key of Object.keys(node)) {
176
+ const value = node[key];
177
+ if (isString(value)) {
172
178
  const out = visitUrls(value, visit);
173
179
  if (out !== value) {
174
- obj[key] = out;
180
+ node[key] = out;
175
181
  }
176
182
  } else {
177
183
  walkStyle(value, visit);
@@ -185,56 +191,53 @@ function walkStyle(node: unknown, visit: RefVisitor): void {
185
191
  * This is the single definition of "what counts as a reference" in a Jx document. Mutates `doc`
186
192
  * only where the visitor asks it to.
187
193
  */
188
- export function walkDocRefs(node: unknown, visit: RefVisitor): void {
194
+ export function walkDocRefs(node: JsonValue | undefined, visit: RefVisitor): void {
189
195
  if (Array.isArray(node)) {
190
- for (const item of node as unknown[]) {
196
+ for (const item of node) {
191
197
  walkDocRefs(item, visit);
192
198
  }
193
199
  return;
194
200
  }
195
- if (!node || typeof node !== "object") {
201
+ if (!isJsonObject(node)) {
196
202
  return;
197
203
  }
198
- const obj = node as Record<string, unknown>;
199
- for (const key of Object.keys(obj)) {
200
- const value = obj[key];
204
+ for (const key of Object.keys(node)) {
205
+ const value = node[key];
201
206
  switch (key) {
202
207
  case "$ref": {
203
208
  // File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
204
- visitScalar(obj, key, "$ref", visit, false);
209
+ visitScalar(node, key, "$ref", visit, false);
205
210
  break;
206
211
  }
207
212
  case "$layout": {
208
- visitScalar(obj, key, "$layout", visit, true);
213
+ visitScalar(node, key, "$layout", visit, true);
209
214
  break;
210
215
  }
211
216
  case "$src":
212
217
  case "$implementation": {
213
- visitScalar(obj, key, key, visit, false);
218
+ visitScalar(node, key, key, visit, false);
214
219
  break;
215
220
  }
216
221
  case "src":
217
222
  case "href": {
218
- visitScalar(obj, key, "attr", visit, false);
223
+ visitScalar(node, key, "attr", visit, false);
219
224
  break;
220
225
  }
221
226
  case "imports": {
222
- if (value && typeof value === "object" && !Array.isArray(value)) {
223
- const imp = value as Record<string, unknown>;
224
- for (const name of Object.keys(imp)) {
225
- visitScalar(imp, name, "imports", visit, false);
227
+ if (isJsonObject(value)) {
228
+ for (const name of Object.keys(value)) {
229
+ visitScalar(value, name, "imports", visit, false);
226
230
  }
227
231
  }
228
232
  break;
229
233
  }
230
234
  case "$elements": {
231
235
  if (Array.isArray(value)) {
232
- const els = value as unknown[];
233
- for (let i = 0; i < els.length; i += 1) {
234
- if (typeof els[i] === "string") {
235
- visitIndexed(els, i, "$elements", visit);
236
+ for (let i = 0; i < value.length; i += 1) {
237
+ if (isString(value[i])) {
238
+ visitIndexed(value, i, "$elements", visit);
236
239
  } else {
237
- walkDocRefs(els[i], visit);
240
+ walkDocRefs(value[i], visit);
238
241
  }
239
242
  }
240
243
  } else {
@@ -255,8 +258,8 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
255
258
  * renaming a file at the project root could rewrite an unrelated copy DESTINATION that
256
259
  * merely shared its name.
257
260
  */
258
- if (value && typeof value === "object" && !Array.isArray(value)) {
259
- visitKeys(value as Record<string, unknown>, "copy", visit, true);
261
+ if (isJsonObject(value)) {
262
+ visitKeys(value, "copy", visit, true);
260
263
  }
261
264
  break;
262
265
  }
@@ -272,7 +275,7 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
272
275
  * resolve-and-compare gate, which only matches a value resolving to the file or directory
273
276
  * in question. A remote `https://…` source is filtered by `classifyRef` before that.
274
277
  */
275
- visitScalar(obj, key, "source", visit, false);
278
+ visitScalar(node, key, "source", visit, false);
276
279
  break;
277
280
  }
278
281
  /*
@@ -292,17 +295,16 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
292
295
  * file in question.
293
296
  */
294
297
  default: {
295
- if (typeof value === "string") {
298
+ if (isString(value)) {
296
299
  if (!PROSE_KEYS.has(key) && looksLikeFileRef(value)) {
297
- visitScalar(obj, key, "path", visit, false);
300
+ visitScalar(node, key, "path", visit, false);
298
301
  }
299
302
  } else if (Array.isArray(value)) {
300
- const arr = value as unknown[];
301
- for (let i = 0; i < arr.length; i += 1) {
302
- const item = arr[i];
303
- if (typeof item === "string") {
303
+ for (let i = 0; i < value.length; i += 1) {
304
+ const item = value[i];
305
+ if (isString(item)) {
304
306
  if (!PROSE_KEYS.has(key) && looksLikeFileRef(item)) {
305
- visitIndexed(arr, i, "path", visit);
307
+ visitIndexed(value, i, "path", visit);
306
308
  }
307
309
  } else {
308
310
  walkDocRefs(item, visit);
@@ -317,7 +319,7 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
317
319
  }
318
320
 
319
321
  /** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
320
- export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
322
+ export function rewriteDocRefs(doc: JsonValue, ctx: RemapCtx): RewriteResult {
321
323
  const changes: RefChange[] = [];
322
324
  walkDocRefs(doc, (value, refType, rootRelativeBare) => {
323
325
  const cls = classifyRef(value);
@@ -339,29 +341,28 @@ export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
339
341
  * null, they are only counted — the read side of the same traversal.
340
342
  */
341
343
  function walkTags(
342
- node: unknown,
344
+ node: JsonValue | undefined,
343
345
  oldTag: string,
344
346
  newTag: string | null,
345
347
  counter: { n: number },
346
348
  ): void {
347
349
  if (Array.isArray(node)) {
348
- for (const item of node as unknown[]) {
350
+ for (const item of node) {
349
351
  walkTags(item, oldTag, newTag, counter);
350
352
  }
351
353
  return;
352
354
  }
353
- if (!node || typeof node !== "object") {
355
+ if (!isJsonObject(node)) {
354
356
  return;
355
357
  }
356
- const obj = node as Record<string, unknown>;
357
- if (obj.tagName === oldTag) {
358
+ if (node.tagName === oldTag) {
358
359
  if (newTag !== null) {
359
- obj.tagName = newTag;
360
+ node.tagName = newTag;
360
361
  }
361
362
  counter.n += 1;
362
363
  }
363
- for (const key of Object.keys(obj)) {
364
- walkTags(obj[key], oldTag, newTag, counter);
364
+ for (const key of Object.keys(node)) {
365
+ walkTags(node[key], oldTag, newTag, counter);
365
366
  }
366
367
  }
367
368
 
@@ -371,11 +372,7 @@ function walkTags(
371
372
  * `$elements`, `cases`) are intentionally left to `rewriteDocRefs`, so the two passes compose.
372
373
  * Mutates `doc`.
373
374
  */
374
- export function rewriteTagName(
375
- doc: unknown,
376
- oldTag: string,
377
- newTag: string,
378
- ): { doc: unknown; count: number } {
375
+ export function rewriteTagName(doc: JsonValue, oldTag: string, newTag: string): TagRewriteResult {
379
376
  const counter = { n: 0 };
380
377
  if (oldTag !== newTag) {
381
378
  walkTags(doc, oldTag, newTag, counter);
@@ -389,7 +386,7 @@ export function rewriteTagName(
389
386
  * A component's OWN definition file answers 1 for its root node, which is why the usage query skips
390
387
  * the definition file rather than subtracting one — a component may legitimately nest itself.
391
388
  */
392
- export function countTagUses(doc: unknown, tag: string): number {
389
+ export function countTagUses(doc: JsonValue, tag: string): number {
393
390
  const counter = { n: 0 };
394
391
  walkTags(doc, tag, null, counter);
395
392
  return counter.n;