@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 +2 -2
- package/src/_lib/bun-lock-packages.ts +1 -1
- package/src/_lib/guard-kit.ts +7 -2
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +93 -47
- package/src/check-complexity.ts +1 -1
- package/src/check-single-runtime-instance.ts +9 -3
- package/src/cli.ts +1 -1
- package/src/guard-admin-api.ts +3 -3
- package/src/guard-html-escape.ts +2 -2
- package/src/guard-raw-sql.ts +1 -1
- package/src/guard-thin-wrappers.ts +2 -3
- package/src/guard-upgrade-state.ts +20 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "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.
|
|
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 =
|
|
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))
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -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"))
|
|
587
|
-
return pkg
|
|
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",
|
package/src/check-as-casts.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `as X` cast audit with a baseline regression
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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 (
|
|
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
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
function
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
console.log(
|
|
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.
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
//
|
|
713
|
-
//
|
|
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
|
|
package/src/check-complexity.ts
CHANGED
|
@@ -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 && (
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
37
|
+
return SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function printHelp(): void {
|
package/src/guard-admin-api.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
|
-
import { type CallExpression, type
|
|
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.
|
|
88
|
-
return
|
|
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();
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -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 (!(
|
|
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 (
|
|
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
|
|
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
|
|
65
|
-
const v = value as Record<string, unknown>;
|
|
68
|
+
if (!isRecord(value)) return false;
|
|
66
69
|
return (
|
|
67
|
-
typeof
|
|
68
|
-
typeof
|
|
69
|
-
typeof
|
|
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
|
|
75
|
-
const
|
|
76
|
-
const installed = v["installedVersion"];
|
|
77
|
+
if (!isRecord(value)) return false;
|
|
78
|
+
const installed = value["installedVersion"];
|
|
77
79
|
return (
|
|
78
|
-
typeof
|
|
80
|
+
typeof value["currentVersion"] === "string" &&
|
|
79
81
|
(installed === undefined || installed === null || typeof installed === "string") &&
|
|
80
|
-
Array.isArray(
|
|
81
|
-
|
|
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
|
|
87
|
-
const v = value as Record<string, unknown>;
|
|
88
|
+
if (!isRecord(value)) return false;
|
|
88
89
|
return (
|
|
89
|
-
typeof
|
|
90
|
-
|
|
91
|
-
typeof
|
|
92
|
-
typeof
|
|
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`,
|