@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.384.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.384.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.384.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",
@@ -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 { violations: reportBaseline(all) };
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
+ }
@@ -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
- // Bun.SQL timestamptz columns arrive as native Date (driver wire format,
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 = [