@cosmicdrift/kumiko-guards 0.384.0 → 0.385.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 +3 -3
- package/src/changes.json +7 -0
- package/src/check-as-casts.ts +46 -1
- package/src/guard-file-size.ts +93 -0
- package/src/guard-no-date-api.ts +1 -1
- package/src/run-guards.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.385.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>",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.385.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.385.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.385.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "File Size guard and per-package ratchet for legit casts",
|
|
6
|
+
"detail": "The File Size guard freezes production files above 2000 lines in `.kumiko-file-size-baseline.json` and fails when a file grows or a new one crosses the limit. The As-Casts Audit additionally caps legit-bridge and legit-boundary casts per package in `.kumiko-cast-legit-baseline.json`. Without the baseline files both only warn.",
|
|
7
|
+
"migration": "Optional. Freeze both with `kumiko-guards guards --write-baseline --guard=\"File Size\"` and `--guard=\"As-Casts Audit\"`."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.384.0",
|
|
4
11
|
"type": "improvement",
|
package/src/check-as-casts.ts
CHANGED
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
* • an unreadable or format-drifted baseline
|
|
36
36
|
* Without a baseline file (e.g. consumer repos) nothing fails: warnings only.
|
|
37
37
|
* Sibling checkouts (`../`) never fail.
|
|
38
|
+
* `.kumiko-cast-legit-baseline.json` additionally caps legit-bridge and
|
|
39
|
+
* legit-boundary casts per package (`<package-dir>:<category>`), so a new
|
|
40
|
+
* double cast or boundary marker cannot slip in unnoticed. Per package rather
|
|
41
|
+
* than per file, so moving a cast between files does not trip it. Same
|
|
42
|
+
* missing-file behavior (warning only) as the suspect baseline.
|
|
43
|
+
*
|
|
38
44
|
* Reductions (current < baseline) are allowed but do NOT update the baseline
|
|
39
45
|
* automatically — run `--write-baseline` after cleanup commits.
|
|
40
46
|
*
|
|
@@ -49,6 +55,7 @@ import * as path from "node:path";
|
|
|
49
55
|
import { type AsExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
50
56
|
import {
|
|
51
57
|
type AstGuard,
|
|
58
|
+
baselineRatchet,
|
|
52
59
|
buildSharedProject,
|
|
53
60
|
filesForGuard,
|
|
54
61
|
type GuardOutcome,
|
|
@@ -550,6 +557,33 @@ function totalOf(byFileAndTarget: Record<string, Record<string, number>>): numbe
|
|
|
550
557
|
|
|
551
558
|
const baselinePath = path.join(ROOT, ".kumiko-cast-baseline.json");
|
|
552
559
|
|
|
560
|
+
const LEGIT_BASELINE_FORMAT_VERSION = 1;
|
|
561
|
+
const LEGIT_CAPPED_CATEGORIES: readonly Category[] = ["legit-bridge", "legit-boundary"];
|
|
562
|
+
|
|
563
|
+
const legitRatchet = baselineRatchet({
|
|
564
|
+
file: path.join(ROOT, ".kumiko-cast-legit-baseline.json"),
|
|
565
|
+
formatVersion: LEGIT_BASELINE_FORMAT_VERSION,
|
|
566
|
+
unit: "legit cast(s)",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
function packageDirOf(file: string): string {
|
|
570
|
+
return /^(?:packages|samples)\/[^/]+/.exec(file)?.[0] ?? file.split("/")[0] ?? file;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function legitCountsByPackage(all: readonly Site[]): Record<string, number> {
|
|
574
|
+
const counts: Record<string, number> = {};
|
|
575
|
+
for (const site of all) {
|
|
576
|
+
if (isSiblingFile(site.file)) continue;
|
|
577
|
+
if (!LEGIT_CAPPED_CATEGORIES.includes(site.category)) continue;
|
|
578
|
+
const key = `${packageDirOf(site.file)}:${site.category}`;
|
|
579
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
580
|
+
}
|
|
581
|
+
return counts;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const LEGIT_REMEDIATION =
|
|
585
|
+
'Avoid the cast (type guard, discriminated union, typing at the source). Only if it is a real system boundary or deliberate bridge: re-freeze with `bun packages/guards/src/cli.ts guards --write-baseline --guard="As-Casts Audit"` and justify it in the PR.';
|
|
586
|
+
|
|
553
587
|
function writeBaseline(all: readonly Site[]): void {
|
|
554
588
|
const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
|
|
555
589
|
const payload: Baseline = {
|
|
@@ -568,6 +602,7 @@ function writeBaseline(all: readonly Site[]): void {
|
|
|
568
602
|
writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
569
603
|
console.log(`\n Baseline written: ${baselinePath}`);
|
|
570
604
|
console.log(` Total suspect: ${payload.totalSuspect} (repo-local)`);
|
|
605
|
+
legitRatchet.write(legitCountsByPackage(all));
|
|
571
606
|
}
|
|
572
607
|
|
|
573
608
|
// Per File:target: aktueller Count gegen baseline. Mehr → Regression.
|
|
@@ -758,13 +793,23 @@ function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): G
|
|
|
758
793
|
}
|
|
759
794
|
// Fails on regressions, repo-local unknown reasons (both only with a valid
|
|
760
795
|
// baseline) and an unreadable or drifted baseline; see the module header.
|
|
761
|
-
return {
|
|
796
|
+
return {
|
|
797
|
+
violations: [
|
|
798
|
+
...reportBaseline(all),
|
|
799
|
+
...legitRatchet.check(legitCountsByPackage(all), LEGIT_REMEDIATION),
|
|
800
|
+
],
|
|
801
|
+
};
|
|
762
802
|
}
|
|
763
803
|
|
|
764
804
|
export const guard: AstGuard = {
|
|
765
805
|
name: "As-Casts Audit",
|
|
766
806
|
scan: SCAN,
|
|
767
807
|
run: (files) => analyseCasts(files, true),
|
|
808
|
+
writeBaseline: (files) => {
|
|
809
|
+
const { all, scanned } = scanCasts(files);
|
|
810
|
+
reportCasts(all, scanned);
|
|
811
|
+
writeBaseline(all);
|
|
812
|
+
},
|
|
768
813
|
};
|
|
769
814
|
|
|
770
815
|
if (import.meta.main) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* File Size Guard — production files above 2000 lines are frozen in
|
|
4
|
+
* `.kumiko-file-size-baseline.json` (key = file, value = line count) and may
|
|
5
|
+
* not grow; a new file above the threshold is a violation. Without a baseline
|
|
6
|
+
* file (consumer repos) it only warns.
|
|
7
|
+
*
|
|
8
|
+
* Excluded: tests, declaration files, generated files and the translation
|
|
9
|
+
* data in the locale packages' `src/strings.ts`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import type { SourceFile } from "ts-morph";
|
|
14
|
+
import {
|
|
15
|
+
type AstGuard,
|
|
16
|
+
baselineRatchet,
|
|
17
|
+
buildSharedProject,
|
|
18
|
+
filesForGuard,
|
|
19
|
+
isLocalFinding,
|
|
20
|
+
relFromRepoRoot,
|
|
21
|
+
runStandalone,
|
|
22
|
+
type ScanSpec,
|
|
23
|
+
} from "./_lib/guard-kit";
|
|
24
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
25
|
+
|
|
26
|
+
const ROOT = process.cwd();
|
|
27
|
+
|
|
28
|
+
const SCAN: ScanSpec = {
|
|
29
|
+
scope: "source",
|
|
30
|
+
extensions: ["ts", "tsx"],
|
|
31
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const MAX_FILE_LINES = 2000;
|
|
35
|
+
|
|
36
|
+
const EXCLUDE =
|
|
37
|
+
/(__tests__|\.test\.(ts|tsx)$|\.integration\.(ts|tsx)$|\.d\.(ts|tsx)$|\.generated\.(ts|tsx)$|\/generated\/|\/locale-[^/]+\/src\/strings\.ts$)/;
|
|
38
|
+
|
|
39
|
+
const fileSizeBaseline = baselineRatchet({
|
|
40
|
+
file: path.join(ROOT, ".kumiko-file-size-baseline.json"),
|
|
41
|
+
formatVersion: 1,
|
|
42
|
+
unit: "oversized file(s)",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function lineCountOf(text: string): number {
|
|
46
|
+
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function oversizedFileLines(
|
|
50
|
+
files: readonly SourceFile[],
|
|
51
|
+
roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
|
|
52
|
+
): Record<string, number> {
|
|
53
|
+
const counts: Record<string, number> = {};
|
|
54
|
+
for (const sf of files) {
|
|
55
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
56
|
+
const lines = lineCountOf(sf.getFullText());
|
|
57
|
+
if (lines <= MAX_FILE_LINES) continue;
|
|
58
|
+
counts[relFromRepoRoot(sf.getFilePath(), roots)] = lines;
|
|
59
|
+
}
|
|
60
|
+
return counts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function localOnly(counts: Readonly<Record<string, number>>): Record<string, number> {
|
|
64
|
+
return Object.fromEntries(Object.entries(counts).filter(([file]) => isLocalFinding({ file })));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const REMEDIATION = `Split the file below ${MAX_FILE_LINES + 1} lines (cut along a responsibility seam), do not grow a frozen one.`;
|
|
68
|
+
|
|
69
|
+
export const guard: AstGuard = {
|
|
70
|
+
name: "File Size",
|
|
71
|
+
scan: SCAN,
|
|
72
|
+
hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="File Size"`',
|
|
73
|
+
run: (files, roots = resolveRepoRoots()) => {
|
|
74
|
+
const counts = localOnly(oversizedFileLines(files, roots));
|
|
75
|
+
return {
|
|
76
|
+
violations: fileSizeBaseline.check(counts, REMEDIATION, {
|
|
77
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="File Size"\` once.`,
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
writeBaseline: (files, roots = resolveRepoRoots()) => {
|
|
82
|
+
fileSizeBaseline.write(localOnly(oversizedFileLines(files, roots)));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (import.meta.main) {
|
|
87
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
88
|
+
const project = buildSharedProject([guard]);
|
|
89
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
runStandalone(guard);
|
|
93
|
+
}
|
package/src/guard-no-date-api.ts
CHANGED
|
@@ -107,7 +107,7 @@ const ALLOWLIST = [
|
|
|
107
107
|
// HTTP Last-Modified/ETag header: Bun/Node Response API expects a native
|
|
108
108
|
// Date object from fs.Stats.mtimeMs — wire boundary, not a Temporal format.
|
|
109
109
|
/packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
|
|
110
|
-
//
|
|
110
|
+
// postgres-js timestamptz columns arrive as native Date (driver wire format,
|
|
111
111
|
// same reason as packages/framework/src/db/ above).
|
|
112
112
|
/packages\/publish\/src\/rollback\.ts$/,
|
|
113
113
|
];
|
package/src/run-guards.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { guard as directFetch } from "./guard-direct-fetch";
|
|
|
31
31
|
import { guard as errorReasons } from "./guard-error-reasons";
|
|
32
32
|
import { guard as eventStoreWrites } from "./guard-event-store-writes";
|
|
33
33
|
import { guard as fakeTests } from "./guard-fake-tests";
|
|
34
|
+
import { guard as fileSize } from "./guard-file-size";
|
|
34
35
|
import { guard as htmlEscape } from "./guard-html-escape";
|
|
35
36
|
import { guard as i18nKeys } from "./guard-i18n-keys";
|
|
36
37
|
import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
|
|
@@ -80,6 +81,7 @@ export const GUARDS = [
|
|
|
80
81
|
libTestCoverage,
|
|
81
82
|
testTimeouts,
|
|
82
83
|
testTemplateDrift,
|
|
84
|
+
fileSize,
|
|
83
85
|
];
|
|
84
86
|
|
|
85
87
|
export const GUARD_FLAGS = [
|