@cosmicdrift/kumiko-guards 0.361.0 → 0.363.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.361.0",
3
+ "version": "0.363.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.361.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.363.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -18,7 +18,7 @@ type ParsedBunLock = { readonly packages?: Record<string, unknown> };
18
18
 
19
19
  function isParsedBunLock(value: unknown): value is ParsedBunLock {
20
20
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
21
- const packages = (value as Record<string, unknown>)["packages"];
21
+ const packages = "packages" in value ? value.packages : undefined;
22
22
  return (
23
23
  packages === undefined ||
24
24
  (packages !== null && typeof packages === "object" && !Array.isArray(packages))
@@ -583,8 +583,13 @@ function guardKitVersion(): string {
583
583
  const pkgPath = resolve(import.meta.dir, "../../package.json");
584
584
  // The version only decorates the banner; it must never abort a guard run.
585
585
  try {
586
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
587
- return pkg.version ?? "unknown";
586
+ const pkg: unknown = JSON.parse(readFileSync(pkgPath, "utf-8"));
587
+ return typeof pkg === "object" &&
588
+ pkg !== null &&
589
+ "version" in pkg &&
590
+ typeof pkg.version === "string"
591
+ ? pkg.version
592
+ : "unknown";
588
593
  } catch {
589
594
  return "unknown";
590
595
  }
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "improvement",
5
+ "title": "As-casts guard fails on baseline regressions",
6
+ "detail": "check-as-casts now returns violations for more suspect casts per file and target than the baseline, for unknown or missing @cast-boundary reasons in repo-local files (only when a baseline exists), and for a baseline with a drifted format. Repos without a baseline and sibling checkouts under ../ still only warn."
7
+ },
2
8
  {
3
9
  "version": "0.361.0",
4
10
  "type": "fix",
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * `as X` cast audit with a baseline regression report (WARNING, no fail).
4
+ * `as X` cast audit with a baseline regression gate (fails on regressions).
5
5
  *
6
6
  * Every `as X` cast is a compiler gag. This check shows all casts
7
7
  * in production code, categorized as:
@@ -27,10 +27,14 @@
27
27
  * Baseline regression report:
28
28
  *
29
29
  * `.kumiko-cast-baseline.json` in the repo root pins the expected
30
- * suspect-cast count per file. The audit compares against the baseline and
31
- * reports increases — per the project coding standards ("Type Assertions")
32
- * this check is "warning, no fail": it never blocks, not even on
33
- * unknown @cast-boundary reasons or a baseline regression.
30
+ * suspect-cast count per file and target type. The audit compares against the
31
+ * baseline and fails on:
32
+ * • a regression (more suspect casts for a file:target than the baseline,
33
+ * repo-local files only)
34
+ * • an unknown or missing `@cast-boundary` reason in a repo-local file
35
+ * • an unreadable or format-drifted baseline
36
+ * Without a baseline file (e.g. consumer repos) nothing fails: warnings only.
37
+ * Sibling checkouts (`../`) never fail.
34
38
  * Reductions (current < baseline) are allowed but do NOT update the baseline
35
39
  * automatically — run `--write-baseline` after cleanup commits.
36
40
  *
@@ -227,11 +231,16 @@ export const KNOWN_BOUNDARY_REASONS = [
227
231
  "projection-table", // implicit entity-projections set `table`
228
232
  // UI / Web
229
233
  "visual-tree-args", // TargetRef.args is erased to generic args shape
234
+ // Brands / third-party gaps
235
+ "phantom-brand", // TenancyBrand-style phantom marker, or a conditional type keyed on one, with no runtime representation
236
+ "third-party-type-gap", // a library's type does not know a valid runtime value (e.g. Hono status 499)
237
+ "dom-event-target", // event.target narrowed to the Element the DOM guarantees
238
+ "feature-exports", // typeof-checked read-back of a function from untyped feature.exports
230
239
  ] as const;
231
240
  export type BoundaryReason = (typeof KNOWN_BOUNDARY_REASONS)[number];
232
241
 
233
242
  export function isKnownBoundaryReason(reason: string): reason is BoundaryReason {
234
- return (KNOWN_BOUNDARY_REASONS as readonly string[]).includes(reason);
243
+ return KNOWN_BOUNDARY_REASONS.some((known) => known === reason);
235
244
  }
236
245
 
237
246
  export function hasBoundaryMarker(cast: AsExpression): boolean {
@@ -338,7 +347,7 @@ export function categorize(cast: AsExpression): Category {
338
347
  return "suspect-general";
339
348
  }
340
349
 
341
- function collect(sf: SourceFile): Site[] {
350
+ export function collect(sf: SourceFile): Site[] {
342
351
  const file = path.relative(ROOT, sf.getFilePath());
343
352
  const sites: Site[] = [];
344
353
  for (const cast of sf.getDescendantsOfKind(SyntaxKind.AsExpression)) {
@@ -361,7 +370,7 @@ function collect(sf: SourceFile): Site[] {
361
370
  line: cast.getStartLineNumber(),
362
371
  category,
363
372
  source: cast.getExpression().getText().slice(0, 60),
364
- target: cast.getTypeNode()?.getText().slice(0, 60) ?? "",
373
+ target: cast.getTypeNode()?.getText().replace(/\s+/g, " ").slice(0, 60) ?? "",
365
374
  full: cast.getText().slice(0, 100),
366
375
  ...(reason ? { boundaryReason: reason } : {}),
367
376
  });
@@ -467,28 +476,34 @@ function reportCasts(all: readonly Site[], scanned: number): void {
467
476
  );
468
477
  }
469
478
 
470
- // Reason validation: every legit-boundary cast should have a known reason.
471
- // Unknown reasons are reported (reason drift like "engine-payload" vs
472
- // "enginePayload" would otherwise destroy the audit's value in the long
473
- // run) — extending the whitelist: add an entry to KNOWN_BOUNDARY_REASONS
474
- // above. Warning, no fail (see module header).
475
- function reportUnknownReasons(all: readonly Site[]): void {
476
- const unknownReasons: Array<{ file: string; line: number; reason: string }> = [];
477
- for (const s of all) {
478
- if (s.category !== "legit-boundary") continue;
479
- const r = s.boundaryReason ?? "";
480
- if (r === "" || !isKnownBoundaryReason(r)) {
481
- unknownReasons.push({ file: s.file, line: s.line, reason: r || "<missing>" });
482
- }
483
- }
484
- if (unknownReasons.length > 0) {
485
- console.log(`\n UNKNOWN @cast-boundary REASONS (${unknownReasons.length}):`);
486
- for (const u of unknownReasons) {
487
- console.log(` ${u.file}:${u.line} reason=${u.reason}`);
488
- }
489
- console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
490
- console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
479
+ // Reason validation: every legit-boundary cast needs a known reason (reason
480
+ // drift like "engine-payload" vs "enginePayload" would otherwise destroy the
481
+ // audit's value). Extending the whitelist: add an entry to
482
+ // KNOWN_BOUNDARY_REASONS above. Always printed; only repo-local sites fail,
483
+ // and only once a baseline gates the repo (see reportBaseline).
484
+ function findUnknownReasons(all: readonly Site[]): Site[] {
485
+ return all.filter(
486
+ (s) =>
487
+ s.category === "legit-boundary" &&
488
+ (s.boundaryReason === undefined ||
489
+ s.boundaryReason === "" ||
490
+ !isKnownBoundaryReason(s.boundaryReason)),
491
+ );
492
+ }
493
+
494
+ function reportUnknownReasons(unknown: readonly Site[]): void {
495
+ // skip: every reason is known, nothing to report
496
+ if (unknown.length === 0) return;
497
+ console.log(`\n UNKNOWN @cast-boundary REASONS (${unknown.length}):`);
498
+ for (const u of unknown) {
499
+ console.log(` ${u.file}:${u.line} reason=${u.boundaryReason || "<missing>"}`);
491
500
  }
501
+ console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
502
+ console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
503
+ }
504
+
505
+ function isSiblingFile(file: string): boolean {
506
+ return file.startsWith("../");
492
507
  }
493
508
 
494
509
  const BASELINE_FORMAT_VERSION = 2;
@@ -557,7 +572,7 @@ function writeBaseline(all: readonly Site[]): void {
557
572
 
558
573
  // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
559
574
  // A cast swap (cast A removed, cast B with a different target added) is detected
560
- // this way although the total stays stable. Report only — warning, no fail.
575
+ // this way although the total stays stable.
561
576
  // Merge-conflict markers, truncated writes and hand edits all land here; the
562
577
  // file is read once at the boundary so the comparison below can trust its shape.
563
578
  function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
@@ -635,6 +650,11 @@ function compareCastsToBaseline(
635
650
  return { regressions, reduced };
636
651
  }
637
652
 
653
+ const CAST_OPTIONS_HINT =
654
+ "New casts need a justification. Options: (1) avoid the cast (TypeGuard, Discriminated Union, typing at the source); " +
655
+ "(2) a real system boundary: `// @cast-boundary <reason>` with a reason from KNOWN_BOUNDARY_REASONS; " +
656
+ "(3) re-freeze with `bun packages/guards/src/check-as-casts.ts --write-baseline`, only with a justification in the PR.";
657
+
638
658
  function printCastRegressions(regressions: readonly CastRegression[]): void {
639
659
  console.log(
640
660
  `\n REGRESSION: ${regressions.length} (file, target) pair(s) have more suspect casts than the baseline:`,
@@ -644,13 +664,32 @@ function printCastRegressions(regressions: readonly CastRegression[]): void {
644
664
  ` ${r.file}: as ${r.target} baseline=${r.baseline} current=${r.current} (+${r.current - r.baseline})`,
645
665
  );
646
666
  }
647
- console.log(
648
- "\n New casts need a justification. Options:\n" +
649
- " 1. Avoid the cast (TypeGuard, Discriminated Union, better typing at the source)\n" +
650
- " 2. If a legit system boundary: add a `// @cast-boundary <reason>` marker\n" +
651
- " 3. If a cleanup reduction in one file offsets an increase in another: " +
652
- "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
653
- );
667
+ console.log(`\n${CAST_OPTIONS_HINT}`);
668
+ }
669
+
670
+ function regressionViolation(regression: CastRegression, all: readonly Site[]): GuardViolation {
671
+ const firstNew = all
672
+ .filter(
673
+ (s) =>
674
+ s.file === regression.file &&
675
+ s.target === regression.target &&
676
+ s.category.startsWith("suspect-"),
677
+ )
678
+ .sort((a, b) => a.line - b.line)[regression.baseline];
679
+ return {
680
+ file: regression.file,
681
+ line: firstNew?.line ?? 1,
682
+ message: `Suspect cast regression: \`as ${regression.target}\` baseline=${regression.baseline}, current=${regression.current}. ${CAST_OPTIONS_HINT}`,
683
+ };
684
+ }
685
+
686
+ function unknownReasonViolation(site: Site): GuardViolation {
687
+ const reason = site.boundaryReason || "<missing>";
688
+ return {
689
+ file: site.file,
690
+ line: site.line,
691
+ message: `Unknown @cast-boundary reason ${reason}. Use one of: ${KNOWN_BOUNDARY_REASONS.join(", ")} (or add a reason to KNOWN_BOUNDARY_REASONS in check-as-casts.ts).`,
692
+ };
654
693
  }
655
694
 
656
695
  export function reportBaseline(
@@ -678,19 +717,27 @@ export function reportBaseline(
678
717
  ];
679
718
  }
680
719
  if (loaded.kind === "format-drift") {
681
- console.log(
682
- `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
683
- );
684
- console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
685
- return [];
720
+ // A stale baseline must not silently switch the gate off.
721
+ return [
722
+ {
723
+ file: baselineFile,
724
+ line: 1,
725
+ message: `Cast baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}. Regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
726
+ },
727
+ ];
686
728
  }
687
729
  const { baseline } = loaded;
688
730
 
689
731
  const { regressions, reduced } = compareCastsToBaseline(repoLocalSuspects, baseline.perFile);
690
732
  if (regressions.length > 0) {
691
733
  printCastRegressions(regressions);
692
- return [];
693
734
  }
735
+ const unknownReasons = findUnknownReasons(all).filter((s) => !isSiblingFile(s.file));
736
+ const violations = [
737
+ ...regressions.map((r) => regressionViolation(r, all)),
738
+ ...unknownReasons.map(unknownReasonViolation),
739
+ ];
740
+ if (violations.length > 0) return violations;
694
741
 
695
742
  console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
696
743
  if (reduced > 0) {
@@ -704,14 +751,13 @@ export function reportBaseline(
704
751
  function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
705
752
  const { all, scanned } = scanCasts(files);
706
753
  reportCasts(all, scanned);
707
- reportUnknownReasons(all);
754
+ reportUnknownReasons(findUnknownReasons(all));
708
755
  if (!compareBaseline) {
709
756
  console.log("\n Baseline comparison skipped (--no-baseline).");
710
757
  return { violations: [] };
711
758
  }
712
- // WARNING, no fail (coding-standards.md → "Type Assertions"): neither
713
- // unknown @cast-boundary reasons nor a baseline regression block. Only
714
- // an unreadable baseline fails.
759
+ // Fails on regressions, repo-local unknown reasons (both only with a valid
760
+ // baseline) and an unreadable or drifted baseline; see the module header.
715
761
  return { violations: reportBaseline(all) };
716
762
  }
717
763
 
@@ -145,7 +145,7 @@ export function computeComplexity(fn: Node): number {
145
145
  fn.forEachDescendant((node, traversal) => {
146
146
  // skip: decision points inside a nested function-like node belong to its
147
147
  // own Hotspot entry
148
- if (node !== fn && (FUNCTION_KINDS as readonly SyntaxKind[]).includes(node.getKind())) {
148
+ if (node !== fn && FUNCTION_KINDS.some((kind) => kind === node.getKind())) {
149
149
  traversal.skip();
150
150
  return;
151
151
  }
@@ -37,9 +37,15 @@ function readRuntimeMarker(rootAbsPath: string, packageName: string): string | u
37
37
  const pkgPath = join(dir, "node_modules", packageName, "package.json");
38
38
  if (existsSync(pkgPath)) {
39
39
  try {
40
- const marker = (
41
- JSON.parse(readFileSync(pkgPath, "utf8")) as { kumiko?: { runtime?: unknown } }
42
- ).kumiko?.runtime; // @cast-boundary package-json
40
+ const manifest: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
41
+ const kumikoBlock =
42
+ typeof manifest === "object" && manifest !== null && "kumiko" in manifest
43
+ ? manifest.kumiko
44
+ : undefined;
45
+ const marker =
46
+ typeof kumikoBlock === "object" && kumikoBlock !== null && "runtime" in kumikoBlock
47
+ ? kumikoBlock.runtime
48
+ : undefined;
43
49
  return typeof marker === "string" && ALL_RUNTIMES.has(marker) ? marker : undefined;
44
50
  } catch {
45
51
  return undefined;
package/src/cli.ts CHANGED
@@ -34,7 +34,7 @@ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
34
34
  };
35
35
 
36
36
  function isSubcommand(value: string): value is Subcommand {
37
- return (SUBCOMMANDS as readonly string[]).includes(value);
37
+ return SUBCOMMANDS.some((subcommand) => subcommand === value);
38
38
  }
39
39
 
40
40
  function printHelp(): void {
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as path from "node:path";
23
- import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
23
+ import { type CallExpression, type SourceFile, SyntaxKind } from "ts-morph";
24
24
  import { findRepoRootFor } from "./_lib/baseline-compare";
25
25
  import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
26
26
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
@@ -84,8 +84,8 @@ function isAllowed(relativePath: string): boolean {
84
84
  // references to the known function names.
85
85
  function getCalleeName(call: CallExpression): string | null {
86
86
  const expr = call.getExpression();
87
- if (expr.getKind() === SyntaxKind.Identifier) {
88
- return (expr as Identifier).getText();
87
+ if (expr.isKind(SyntaxKind.Identifier)) {
88
+ return expr.getText();
89
89
  }
90
90
  if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
91
91
  const name = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
@@ -83,7 +83,7 @@ function templateStaticText(tpl: Node): string {
83
83
  if (tpl.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
84
84
  return tpl.getLiteralText();
85
85
  }
86
- const t = tpl as TemplateExpression;
86
+ const t = tpl.asKindOrThrow(SyntaxKind.TemplateExpression);
87
87
  return [
88
88
  t.getHead().getLiteralText(),
89
89
  ...t.getTemplateSpans().map((s) => s.getLiteral().getLiteralText()),
@@ -172,7 +172,7 @@ type LocalFunction = FunctionDeclaration | ArrowFunction | FunctionExpression;
172
172
 
173
173
  function localFunctionsOf(callee: Node, sf: SourceFile): LocalFunction[] | undefined {
174
174
  if (!callee.isKind(SyntaxKind.Identifier)) return undefined;
175
- const decls = localDeclarations(callee as Identifier, sf);
175
+ const decls = localDeclarations(callee, sf);
176
176
  if (decls.length === 0) return undefined;
177
177
  const fns: LocalFunction[] = [];
178
178
  for (const d of decls) {
@@ -78,7 +78,7 @@ async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
78
78
  for (const hit of report.hits) {
79
79
  if (hit.allowed) continue;
80
80
  if (hit.file.includes("/__tests__/")) continue;
81
- if (!(BLOCKING_SQL_KINDS as readonly string[]).includes(hit.kind)) continue;
81
+ if (!BLOCKING_SQL_KINDS.some((kind) => kind === hit.kind)) continue;
82
82
  if (hit.markerSuppressed) {
83
83
  // One line like `asRawClient(db).unsafe(` yields two hits for one marker.
84
84
  if (hit.markerReason !== undefined && hit.markerLine !== undefined) {
@@ -36,7 +36,6 @@ import {
36
36
  Project,
37
37
  type SourceFile,
38
38
  SyntaxKind,
39
- type VariableDeclaration,
40
39
  } from "ts-morph";
41
40
  import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
42
41
  import { frameworkTsConfigPath } from "./_lib/roots";
@@ -62,7 +61,7 @@ export const KNOWN_WRAPPER_REASONS = [
62
61
  export type WrapperReason = (typeof KNOWN_WRAPPER_REASONS)[number];
63
62
 
64
63
  function isKnownReason(r: string): r is WrapperReason {
65
- return (KNOWN_WRAPPER_REASONS as readonly string[]).includes(r);
64
+ return KNOWN_WRAPPER_REASONS.some((known) => known === r);
66
65
  }
67
66
 
68
67
  // ── Built-in filter ─────────────────────────────────────────────────────
@@ -222,7 +221,7 @@ function getFnName(node: FnNode): string | undefined {
222
221
  if (node.isKind(SyntaxKind.FunctionDeclaration)) return node.getName() ?? undefined;
223
222
  const parent = node.getParent();
224
223
  if (parent?.isKind(SyntaxKind.VariableDeclaration)) {
225
- return (parent as VariableDeclaration).getName();
224
+ return parent.getName();
226
225
  }
227
226
  if (parent?.isKind(SyntaxKind.PropertyAssignment)) {
228
227
  return parent.getFirstChild()?.getText();
@@ -60,44 +60,42 @@ export function resolveInstalledVersion(json: UpgradeJson): string {
60
60
  return json.installedVersion ?? json.currentVersion;
61
61
  }
62
62
 
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null;
65
+ }
66
+
63
67
  export function isPendingEntry(value: unknown): value is PendingEntry {
64
- if (!value || typeof value !== "object") return false;
65
- const v = value as Record<string, unknown>;
68
+ if (!isRecord(value)) return false;
66
69
  return (
67
- typeof v["version"] === "string" &&
68
- typeof v["type"] === "string" &&
69
- typeof v["title"] === "string"
70
+ typeof value["version"] === "string" &&
71
+ typeof value["type"] === "string" &&
72
+ typeof value["title"] === "string"
70
73
  );
71
74
  }
72
75
 
73
76
  export function isUpgradeJson(value: unknown): value is UpgradeJson {
74
- if (!value || typeof value !== "object") return false;
75
- const v = value as Record<string, unknown>;
76
- const installed = v["installedVersion"];
77
+ if (!isRecord(value)) return false;
78
+ const installed = value["installedVersion"];
77
79
  return (
78
- typeof v["currentVersion"] === "string" &&
80
+ typeof value["currentVersion"] === "string" &&
79
81
  (installed === undefined || installed === null || typeof installed === "string") &&
80
- Array.isArray(v["pending"]) &&
81
- v["pending"].every(isPendingEntry)
82
+ Array.isArray(value["pending"]) &&
83
+ value["pending"].every(isPendingEntry)
82
84
  );
83
85
  }
84
86
 
85
87
  export function isPendingManualStep(value: unknown): value is PendingManualStep {
86
- if (!value || typeof value !== "object") return false;
87
- const v = value as Record<string, unknown>;
88
+ if (!isRecord(value)) return false;
88
89
  return (
89
- typeof v["id"] === "string" &&
90
- v["id"].length > 0 &&
91
- typeof v["version"] === "string" &&
92
- typeof v["title"] === "string"
90
+ typeof value["id"] === "string" &&
91
+ value["id"].length > 0 &&
92
+ typeof value["version"] === "string" &&
93
+ typeof value["title"] === "string"
93
94
  );
94
95
  }
95
96
 
96
97
  function readPendingManual(parsed: unknown): readonly PendingManualStep[] {
97
- const raw =
98
- parsed && typeof parsed === "object"
99
- ? (parsed as Record<string, unknown>)["pendingManual"]
100
- : undefined;
98
+ const raw = isRecord(parsed) ? parsed["pendingManual"] : undefined;
101
99
  return Array.isArray(raw) ? raw.filter(isPendingManualStep) : [];
102
100
  }
103
101
 
@@ -114,10 +112,7 @@ export function readMarker(root: string): UpgradeMarker | { error: string } {
114
112
  } catch {
115
113
  return { error: `${MARKER_REL} is not valid JSON — broken marker file` };
116
114
  }
117
- const version =
118
- parsed && typeof parsed === "object"
119
- ? (parsed as Record<string, unknown>)["version"]
120
- : undefined;
115
+ const version = isRecord(parsed) ? parsed["version"] : undefined;
121
116
  if (typeof version !== "string" || !SEMVER_RE.test(version)) {
122
117
  return {
123
118
  error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,