@cosmicdrift/kumiko-guards 0.383.1 → 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.383.1",
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.383.1"
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.383.1"
62
+ "@cosmicdrift/kumiko-framework": "0.385.0"
63
63
  }
64
64
  }
package/src/changes.json CHANGED
@@ -1,4 +1,39 @@
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
+ },
9
+ {
10
+ "version": "0.384.0",
11
+ "type": "improvement",
12
+ "title": "Unsafe-Append-Event guard removed",
13
+ "detail": "The event store rejects unregistered event types and validates every payload against its schema at runtime, for appendEvent and unsafeAppendEvent alike, so the static guard is redundant.",
14
+ "migration": "Delete `.kumiko-unsafe-append-event-baseline.json`."
15
+ },
16
+ {
17
+ "version": "0.384.0",
18
+ "type": "improvement",
19
+ "title": "Access-Denied-Test guard replaced by a contract test helper",
20
+ "detail": "Roles are checked centrally before the payload is parsed. The new `expectRoleRestrictedHandlersDenyForeignRole` test helper proves this over HTTP for every role-restricted write and query handler, so the guard that searched for a matching test per handler is removed.",
21
+ "migration": "In an app integration test with setupTestStack and the app features, call `expectRoleRestrictedHandlersDenyForeignRole(stack)` and remove the \"Access-Denied-Test Guard\" entry from `.kumiko-security-baseline.json`."
22
+ },
23
+ {
24
+ "version": "0.384.0",
25
+ "type": "improvement",
26
+ "title": "i18n-Keys guard no longer checks locales",
27
+ "detail": "The guard only reports keys that are used without a definition. Locale completeness moved to the boot validator, which sees the real registered translations of every feature.",
28
+ "migration": "Delete `src/i18n-guard-locales.ts` if it exists."
29
+ },
30
+ {
31
+ "version": "0.384.0",
32
+ "type": "improvement",
33
+ "title": "PII-Annotations guard removed",
34
+ "detail": "Entity text and longText fields now have to declare their personal stance by type and at boot, so the static guard that searched for unannotated fields is redundant.",
35
+ "migration": "Delete `.kumiko-pii-annotations-baseline.json`."
36
+ },
2
37
  {
3
38
  "version": "0.383.0",
4
39
  "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) {
@@ -46,7 +46,7 @@
46
46
  // own production surface — see kumiko-framework#2337 for the one currently
47
47
  // frozen here). Exceptions are tracked via a per-repo baseline file
48
48
  // (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
49
- // mechanism as check-complexity/guard-pii-annotations/etc.), not a
49
+ // mechanism as check-complexity/etc.), not a
50
50
  // hardcoded list in this source file: a consumer repo can freeze or clear
51
51
  // its own findings the same way, with `--write-baseline`.
52
52
  //
@@ -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
+ }
@@ -3,13 +3,13 @@
3
3
  * Guard: i18n keys must be defined before they are used.
4
4
  *
5
5
  * Scan: t() calls in app tsx, definitions from r.translations and i18n bundles.
6
- * Declarative screen/nav keys: validateBoot (runtime).
6
+ * Declarative screen/nav keys and per-feature locale completeness: validateBoot (runtime).
7
7
  *
8
8
  * Usage:
9
9
  * bun guards/guard-i18n-keys.ts
10
10
  */
11
11
 
12
- import { existsSync, readFileSync } from "node:fs";
12
+ import { existsSync } from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import {
15
15
  type Node,
@@ -37,7 +37,6 @@ interface UsedKey {
37
37
 
38
38
  interface DefinedKey {
39
39
  fullKey: string;
40
- locales: Set<string>;
41
40
  file: string;
42
41
  line: number;
43
42
  }
@@ -105,10 +104,10 @@ function findEnclosingFeatureName(node: Node): string | null {
105
104
  return null;
106
105
  }
107
106
 
108
- function extractKeysFromTranslationsObject(
109
- obj: ObjectLiteralExpression,
110
- ): Array<{ key: string; locales: Set<string>; line: number }> {
111
- const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
107
+ type KeyEntry = { key: string; line: number };
108
+
109
+ function extractKeysFromTranslationsObject(obj: ObjectLiteralExpression): KeyEntry[] {
110
+ const out: KeyEntry[] = [];
112
111
  for (const prop of obj.getProperties()) {
113
112
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
114
113
  const nameNode = prop.getNameNode();
@@ -119,23 +118,13 @@ function extractKeysFromTranslationsObject(
119
118
  keyName = nameNode.getText();
120
119
  } else continue;
121
120
 
122
- const initializer = prop.getInitializer();
123
- if (!initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
124
- const locales = new Set<string>();
125
- for (const localeProp of initializer.getProperties()) {
126
- if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
127
- const localeName = localeProp.getNameNode();
128
- if (localeName.isKind(SyntaxKind.StringLiteral)) locales.add(localeName.getLiteralText());
129
- else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
130
- }
131
- out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
121
+ if (!prop.getInitializer()?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
122
+ out.push({ key: keyName, line: prop.getStartLineNumber() });
132
123
  }
133
124
  return out;
134
125
  }
135
126
 
136
- function extractLocaleFirstKeys(
137
- obj: ObjectLiteralExpression,
138
- ): Array<{ key: string; locales: Set<string>; line: number }> {
127
+ function extractLocaleFirstKeys(obj: ObjectLiteralExpression): KeyEntry[] {
139
128
  const localeMaps = new Map<string, ObjectLiteralExpression>();
140
129
  for (const prop of obj.getProperties()) {
141
130
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
@@ -158,45 +147,26 @@ function extractLocaleFirstKeys(
158
147
  }
159
148
  }
160
149
 
161
- const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
162
- for (const key of keySet) {
163
- const locales = new Set<string>();
164
- for (const [locale, map] of localeMaps) {
165
- const has = map.getProperties().some((prop) => {
166
- if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
167
- const nameNode = prop.getNameNode();
168
- return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getLiteralText() === key;
169
- });
170
- if (has) locales.add(locale);
171
- }
172
- out.push({ key, locales, line: obj.getStartLineNumber() });
173
- }
174
- return out;
150
+ return [...keySet].map((key) => ({ key, line: obj.getStartLineNumber() }));
175
151
  }
176
152
 
177
- function pushDefined(
178
- defined: DefinedKey[],
179
- fullKey: string,
180
- locales: Set<string>,
181
- file: string,
182
- line: number,
183
- ): void {
184
- defined.push({ fullKey, locales, file, line });
153
+ function pushDefined(defined: DefinedKey[], fullKey: string, file: string, line: number): void {
154
+ defined.push({ fullKey, file, line });
185
155
  }
186
156
 
187
157
  function addDefinedEntries(
188
158
  defined: DefinedKey[],
189
- entries: Array<{ key: string; locales: Set<string>; line: number }>,
159
+ entries: KeyEntry[],
190
160
  file: string,
191
161
  featureName: string | null,
192
162
  ): void {
193
163
  for (const entry of entries) {
194
164
  if (entry.key.includes(":")) {
195
- pushDefined(defined, entry.key, entry.locales, file, entry.line);
165
+ pushDefined(defined, entry.key, file, entry.line);
196
166
  continue;
197
167
  }
198
168
  if (featureName) {
199
- pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
169
+ pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
200
170
  }
201
171
  }
202
172
  }
@@ -255,9 +225,9 @@ function collectInlineKeysInitializer(
255
225
  ): void {
256
226
  if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
257
227
  for (const entry of extractKeysFromTranslationsObject(initializer)) {
258
- pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
228
+ pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
259
229
  if (entry.key.includes(":")) {
260
- pushDefined(defined, entry.key, entry.locales, file, entry.line);
230
+ pushDefined(defined, entry.key, file, entry.line);
261
231
  }
262
232
  }
263
233
  } else if (initializer?.isKind(SyntaxKind.Identifier)) {
@@ -305,77 +275,6 @@ export function isFrameworkMonorepo(root: string): boolean {
305
275
  return existsSync(path.join(root, "packages/framework/package.json"));
306
276
  }
307
277
 
308
- /** Parse `export const NAME = [...]` locale literals. Returns null if absent;
309
- * throws if the export exists but is not a static string-literal array
310
- * (spread / alias / region-tag-only without literals) — silent de/en fallback
311
- * would hide a real declaration (infra#602). */
312
- export function parseLocaleConstArray(source: string, constName: string): string[] | null {
313
- const exportRe = new RegExp(`export\\s+const\\s+${constName}\\s*(?::[^=]+)?=\\s*([^;]+)`);
314
- const match = source.match(exportRe);
315
- if (!match?.[1]) return null;
316
- const rhs = match[1].trim();
317
- if (!rhs.startsWith("[")) {
318
- throw new Error(
319
- `i18n-keys: export const ${constName} is not a static array literal — refuse silent de/en fallback`,
320
- );
321
- }
322
- if (rhs.includes("...")) {
323
- throw new Error(
324
- `i18n-keys: export const ${constName} uses array spreads — refuse partial/silent locale set`,
325
- );
326
- }
327
- const locales = [...rhs.matchAll(/["']([a-z]{2}(?:-[A-Z]{2})?)["']/g)]
328
- .map((m) => m[1])
329
- .filter((l): l is string => l !== undefined);
330
- if (locales.length === 0) {
331
- throw new Error(
332
- `i18n-keys: export const ${constName} = [...] has no string locale literals — refuse silent de/en fallback`,
333
- );
334
- }
335
- return locales;
336
- }
337
-
338
- /** Resolve owning repo root from a guard-relative path (may be `../…`). */
339
- export function repoRootForRelFile(rel: string): string {
340
- const abs = path.isAbsolute(rel) ? rel : path.resolve(ROOT, rel);
341
- let curr = path.dirname(abs);
342
- let fallback: string | undefined;
343
- while (curr !== path.dirname(curr)) {
344
- const base = path.basename(curr);
345
- // Never treat worktree/parent markers as a repo root (infra#602).
346
- if (base === ".." || base === ".wt") {
347
- curr = path.dirname(curr);
348
- continue;
349
- }
350
- if (existsSync(path.join(curr, "packages/framework/package.json"))) {
351
- return curr;
352
- }
353
- if (existsSync(path.join(curr, "package.json"))) {
354
- // Outermost package.json wins so nested packages/* resolve to the repo.
355
- fallback = curr;
356
- }
357
- curr = path.dirname(curr);
358
- }
359
- return fallback ?? ROOT;
360
- }
361
- /** Repo-declared locales for translation completeness; empty set skips the check. */
362
- export function resolveExpectedLocales(root: string = ROOT): Set<string> {
363
- if (isFrameworkMonorepo(root)) return new Set();
364
-
365
- const declPaths: Array<{ rel: string; constName: string }> = [
366
- { rel: "src/i18n-guard-locales.ts", constName: "I18N_GUARD_LOCALES" },
367
- { rel: "src/marketing/locale-routes.ts", constName: "LOCALES" },
368
- ];
369
- for (const { rel, constName } of declPaths) {
370
- const abs = path.join(root, rel);
371
- if (!existsSync(abs)) continue;
372
- const locales = parseLocaleConstArray(readFileSync(abs, "utf-8"), constName);
373
- if (locales) return new Set(locales);
374
- // parseLocaleConstArray throws on unreadable decls; null = no export.
375
- }
376
- return new Set(["de", "en"]);
377
- }
378
-
379
278
  // Published packages ship `dist` (compiled JS) only; workspace links also carry `src`.
380
279
  const INSTALLED_FRAMEWORK_SOURCES = [
381
280
  "kumiko-*/{src,dist}/**/*.{ts,tsx,js}",
@@ -424,8 +323,6 @@ export function collectInstalledFrameworkKeys(root: string = ROOT): Set<string>
424
323
  export type I18nKeysRunOptions = {
425
324
  /** Inject the framework keys of the installed packages — tests must not scan node_modules. */
426
325
  readonly installedFrameworkKeys?: () => ReadonlySet<string>;
427
- /** Inject expected locales per owning root — tests must not rely on process.cwd(). */
428
- readonly expectedLocalesForRoot?: (root: string) => Set<string>;
429
326
  };
430
327
 
431
328
  export function checkI18nKeys(
@@ -451,47 +348,19 @@ export function checkI18nKeys(
451
348
  ? new Set<string>()
452
349
  : (options.installedFrameworkKeys ?? collectInstalledFrameworkKeys)();
453
350
  const missing = undefinedInApp.filter((u) => !installedKeys.has(u.key));
454
- const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
455
- const expectedByRoot = new Map<string, Set<string>>();
456
- const expectedForFile = (rel: string): Set<string> => {
457
- const root = repoRootForRelFile(rel);
458
- let locales = expectedByRoot.get(root);
459
- if (!locales) {
460
- locales = resolveLocales(root);
461
- expectedByRoot.set(root, locales);
462
- }
463
- return locales;
464
- };
465
-
466
- const localeViolations = definedKeys.flatMap((d) => {
467
- const expectedLocales = expectedForFile(d.file);
468
- const missingLocales = [...expectedLocales].filter((l) => !d.locales.has(l));
469
- if (missingLocales.length === 0) return [];
470
- return [
471
- {
472
- file: d.file,
473
- line: d.line,
474
- message: `Key "${d.fullKey}" is missing locale: ${missingLocales.join(", ")}`,
475
- },
476
- ];
477
- });
478
-
479
351
  return {
480
- violations: [
481
- ...missing.map((m) => ({
482
- file: m.file,
483
- line: m.line,
484
- message: `used key without a definition: "${m.key}"`,
485
- })),
486
- ...localeViolations,
487
- ],
352
+ violations: missing.map((m) => ({
353
+ file: m.file,
354
+ line: m.line,
355
+ message: `used key without a definition: "${m.key}"`,
356
+ })),
488
357
  };
489
358
  }
490
359
 
491
360
  export const guard: AstGuard = {
492
361
  name: "i18n-Keys Guard",
493
362
  scan: SCAN,
494
- hint: "Used i18n key without a definition, or missing locale — add the key/locale to the feature's translations map (locales from src/i18n-guard-locales.ts or src/marketing/locale-routes.ts).",
363
+ hint: "Used i18n key without a definition — add the key to the feature's translations map.",
495
364
  run(files) {
496
365
  return checkI18nKeys(files);
497
366
  },
@@ -173,10 +173,9 @@ export const guard: AstGuard = {
173
173
 
174
174
  // Flags are read ONLY here, not in run() — the shared runner
175
175
  // (run-guards.ts/run-ui-guards.ts) runs every guard with the same argv, a
176
- // --write-baseline there must not silently rewrite the baseline (same
177
- // pattern as guard-pii-annotations.ts).
176
+ // --write-baseline there must not silently rewrite the baseline.
178
177
  //
179
- // No --no-baseline: unlike guard-pii-annotations.ts this guard has a
178
+ // No --no-baseline: this guard has a
180
179
  // second, non-baselined rule (PRIMITIVE_NAME) — a --no-baseline that
181
180
  // discards analyse()'s return and exits 0 would silence that too. Nobody
182
181
  // reads the flag (the shared runner only calls run()).
@@ -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
  ];
@@ -22,7 +22,7 @@
22
22
  * compare WITH their modifier prefixes intact (`hover:mb-2` is its own
23
23
  * Tailwind candidate, distinct from `mb-2`).
24
24
  *
25
- * Baseline-regression guard like guard-pii-annotations.ts: pins the
25
+ * Baseline-regression guard: pins the
26
26
  * currently-known violation count per file. Reductions are allowed but
27
27
  * don't auto-update the baseline. Without a baseline file the guard stays
28
28
  * warning-only (bootstrap: run `--write-baseline` once).
package/src/run-guards.ts CHANGED
@@ -23,7 +23,6 @@ import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
23
23
  import { guard as asCasts } from "./check-as-casts";
24
24
  import { guard as complexity } from "./check-complexity";
25
25
  import { guard as predicateExtraction } from "./check-predicates";
26
- import { guard as accessDeniedTest } from "./guard-access-denied-test";
27
26
  import { guard as adminApi } from "./guard-admin-api";
28
27
  import { guard as appFeatureStructure } from "./guard-app-feature-structure";
29
28
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
@@ -32,6 +31,7 @@ import { guard as directFetch } from "./guard-direct-fetch";
32
31
  import { guard as errorReasons } from "./guard-error-reasons";
33
32
  import { guard as eventStoreWrites } from "./guard-event-store-writes";
34
33
  import { guard as fakeTests } from "./guard-fake-tests";
34
+ import { guard as fileSize } from "./guard-file-size";
35
35
  import { guard as htmlEscape } from "./guard-html-escape";
36
36
  import { guard as i18nKeys } from "./guard-i18n-keys";
37
37
  import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
@@ -42,19 +42,16 @@ import { guard as noDateApi } from "./guard-no-date-api";
42
42
  import { guard as noDirectFs } from "./guard-no-direct-fs";
43
43
  import { guard as noLogicInViews } from "./guard-no-logic-in-views";
44
44
  import { guard as noTemporalPolyfillImport } from "./guard-no-temporal-polyfill-import";
45
- import { guard as piiAnnotations } from "./guard-pii-annotations";
46
45
  import { guard as restrictedSymbols } from "./guard-restricted-symbols";
47
46
  import { guard as screenConventions } from "./guard-screen-conventions";
48
47
  import { guard as silentSkip } from "./guard-silent-skip";
49
48
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
50
49
  import { guard as testTemplateDrift } from "./guard-test-template-drift";
51
50
  import { guard as testTimeouts } from "./guard-test-timeouts";
52
- import { guard as unsafeAppendEvent } from "./guard-unsafe-append-event";
53
51
  import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
54
52
  import { guard as writeHandlerQns } from "./guard-write-handler-qns";
55
53
 
56
54
  export const GUARDS = [
57
- accessDeniedTest,
58
55
  adminApi,
59
56
  directEntityWrites,
60
57
  directFetch,
@@ -74,7 +71,6 @@ export const GUARDS = [
74
71
  i18nKeys,
75
72
  i18nLocaleMount,
76
73
  i18nLocaleTerminology,
77
- piiAnnotations,
78
74
  complexity,
79
75
  predicateExtraction,
80
76
  screenConventions,
@@ -85,7 +81,7 @@ export const GUARDS = [
85
81
  libTestCoverage,
86
82
  testTimeouts,
87
83
  testTemplateDrift,
88
- unsafeAppendEvent,
84
+ fileSize,
89
85
  ];
90
86
 
91
87
  export const GUARD_FLAGS = [
@@ -1,167 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: role-restricted write handlers need an access-denied test.
4
- *
5
- * Rule: any `defineWriteHandler`/`*.writeHandler` call whose `access` object
6
- * has a `roles` property (and no `openToAll`, and no anonymous/all role) must
7
- * have a test somewhere in the repo that mentions the handler AND asserts a
8
- * rejection (AccessDeniedError / access_denied / 403). Matching is file-level
9
- * and within the handler's own repo, same contract as guard-tenant-escalation.
10
- *
11
- * Tripwire: a false-negative (a weak name match hiding a real gap) is
12
- * tolerated; a false-positive on a clean repo is not.
13
- *
14
- * Usage:
15
- * bun guards/guard-access-denied-test.ts
16
- * Baseline: kumiko-guards guards --write-security-baseline
17
- */
18
-
19
- import * as path from "node:path";
20
- import {
21
- type CallExpression,
22
- type ObjectLiteralExpression,
23
- type SourceFile,
24
- SyntaxKind,
25
- } from "ts-morph";
26
- import { findRepoRootFor } from "./_lib/baseline-compare";
27
- import {
28
- ALL_REPO_KINDS,
29
- type AstGuard,
30
- type GuardViolation,
31
- runStandalone,
32
- type ScanSpec,
33
- } from "./_lib/guard-kit";
34
- import {
35
- escapeRegExp,
36
- literalStringOf,
37
- mentionsAsWord,
38
- nameForms,
39
- } from "./_lib/handler-name-forms";
40
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
41
- import { TEST_FILE_RE } from "./_lib/test-file";
42
-
43
- const ROOT = process.cwd();
44
- const SCAN: ScanSpec = {
45
- kinds: ALL_REPO_KINDS,
46
- scope: "source+tests",
47
- extensions: ["ts"],
48
- frameworkWithin: ["packages/*/src/**", "samples/**"],
49
- };
50
-
51
- // Any role that lets an unauthenticated/unrestricted caller through means
52
- // nobody is actually excluded by this handler's roles.
53
- const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/;
54
-
55
- type RoleRestrictedHandler = { name: string; file: string; line: number };
56
-
57
- function writeHandlerOptions(call: CallExpression): ObjectLiteralExpression | undefined {
58
- const callee = call.getExpression().getText();
59
- if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) return undefined;
60
- const arg = call.getArguments()[0];
61
- if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
62
- return arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
63
- }
64
-
65
- function accessObjectOf(obj: ObjectLiteralExpression): ObjectLiteralExpression | undefined {
66
- const accessProp = obj.getProperty("access");
67
- if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
68
- const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
69
- if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
70
- return accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
71
- }
72
-
73
- function restrictsToRoles(accessObj: ObjectLiteralExpression): boolean {
74
- if (accessObj.getProperty("openToAll")) return false;
75
- const rolesProp = accessObj.getProperty("roles");
76
- if (!rolesProp) return false;
77
- if (
78
- rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
79
- rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
80
- )
81
- return false;
82
- return !OPEN_ROLE_RE.test(rolesProp.getText());
83
- }
84
-
85
- function roleRestrictedHandlerOf(
86
- call: CallExpression,
87
- filePath: string,
88
- ): RoleRestrictedHandler | undefined {
89
- const obj = writeHandlerOptions(call);
90
- if (!obj) return undefined;
91
- const name = literalStringOf(obj.getProperty("name"));
92
- if (!name) return undefined;
93
- const accessObj = accessObjectOf(obj);
94
- if (!accessObj || !restrictsToRoles(accessObj)) return undefined;
95
- return { name, file: filePath, line: call.getStartLineNumber() };
96
- }
97
-
98
- export function findRoleRestrictedWriteHandlers(
99
- files: readonly SourceFile[],
100
- ): RoleRestrictedHandler[] {
101
- const out: RoleRestrictedHandler[] = [];
102
- for (const sf of files) {
103
- if (TEST_FILE_RE.test(sf.getFilePath())) continue;
104
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
105
- const handler = roleRestrictedHandlerOf(call, sf.getFilePath());
106
- if (handler) out.push(handler);
107
- }
108
- }
109
- return out;
110
- }
111
-
112
- const ACCESS_DENIED_ASSERTION = /\bAccessDeniedError\b|\baccess_denied\b|\b403\b/;
113
-
114
- const SHORT_WORD = /^[a-z0-9]+$/;
115
-
116
- // A single lowercase word ("create", "approve") is common enough in
117
- // unrelated test scaffolding (createTestStack, approveAll) that a bare word-
118
- // boundary match is too loose — only count it as a string-literal end
119
- // ("approve"/"feat:write:approve") or a property access (Handlers.approve).
120
- export function testMentionsHandler(text: string, handlerName: string): boolean {
121
- return nameForms(handlerName).some((form) => {
122
- if (!SHORT_WORD.test(form)) return mentionsAsWord(text, form);
123
- const esc = escapeRegExp(form);
124
- return new RegExp(`[:"'\`]${esc}["'\`]|\\.${esc}\\b`).test(text);
125
- });
126
- }
127
-
128
- export function findHandlersWithoutAccessDeniedTest(
129
- files: readonly SourceFile[],
130
- roots: readonly { readonly absPath: string }[] = [],
131
- ): RoleRestrictedHandler[] {
132
- const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
133
- const testTextsByRepo = new Map<string, string[]>();
134
- for (const sf of files) {
135
- if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
136
- const text = sf.getFullText();
137
- if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
138
- const repoKey = repoKeyOf(sf.getFilePath());
139
- const texts = testTextsByRepo.get(repoKey) ?? [];
140
- texts.push(text);
141
- testTextsByRepo.set(repoKey, texts);
142
- }
143
- return findRoleRestrictedWriteHandlers(files).filter((h) => {
144
- const testTexts = testTextsByRepo.get(repoKeyOf(h.file)) ?? [];
145
- return !testTexts.some((text) => testMentionsHandler(text, h.name));
146
- });
147
- }
148
-
149
- export const guard: AstGuard = {
150
- name: "Access-Denied-Test Guard",
151
- scan: SCAN,
152
- security: true,
153
- hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `kumiko-guards guards --write-security-baseline`.",
154
- run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
155
- // consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
156
- const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
157
- (h) => ({
158
- file: path.relative(ROOT, h.file),
159
- line: h.line,
160
- message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
161
- }),
162
- );
163
- return { violations };
164
- },
165
- };
166
-
167
- if (import.meta.main) runStandalone(guard);
@@ -1,279 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: PII-typical entity field names without annotation, with a baseline
4
- * regression guard like check-complexity.ts.
5
- *
6
- * Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
7
- * fields with { pii: true }, { userOwned }, { tenantOwned: true },
8
- * { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
9
- * successor to the four legacy subject annotations).
10
- *
11
- * `.kumiko-pii-annotations-baseline.json` in the repo root pins the frozen
12
- * finding count per file:
13
- * - current <= baseline per file: PASS
14
- * - current > baseline per file: FAIL (new unannotated PII field)
15
- * Reductions do NOT update the baseline automatically — run `--write-baseline`
16
- * after annotation commits. Without a baseline file the guard stays
17
- * warning-only (bootstrap: run `--write-baseline` once).
18
- *
19
- * Usage:
20
- * bun guards/guard-pii-annotations.ts # compare against baseline
21
- * bun guards/guard-pii-annotations.ts --write-baseline # rewrite baseline
22
- * bun guards/guard-pii-annotations.ts --no-baseline # skip comparison
23
- */
24
- import * as path from "node:path";
25
- import {
26
- type CallExpression,
27
- type Node,
28
- type ObjectLiteralExpression,
29
- type SourceFile,
30
- SyntaxKind,
31
- } from "ts-morph";
32
- import {
33
- type AstGuard,
34
- baselineRatchet,
35
- buildSharedProject,
36
- filesForGuard,
37
- type GuardOutcome,
38
- type GuardViolation,
39
- isLocalFinding,
40
- relFromRepoRoot,
41
- runStandalone,
42
- type ScanSpec,
43
- } from "./_lib/guard-kit";
44
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
45
-
46
- const ROOT = process.cwd();
47
-
48
- const SCAN: ScanSpec = {
49
- scope: "source",
50
- extensions: ["ts"],
51
- frameworkWithin: ["packages/*/src/**"],
52
- };
53
- const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
54
-
55
- // Keep in sync with boot-validator/entity-handler.ts PII_*_NAME_HINTS.
56
- const PII_DIRECT_NAME_HINTS = new Set([
57
- "email",
58
- "phone",
59
- "phonenumber",
60
- "mobile",
61
- "address",
62
- "street",
63
- "postalcode",
64
- "zipcode",
65
- "zip",
66
- "city",
67
- "displayname",
68
- "firstname",
69
- "lastname",
70
- "fullname",
71
- "birthday",
72
- "birthdate",
73
- "dateofbirth",
74
- "dob",
75
- "ssn",
76
- "taxid",
77
- "vatid",
78
- "passport",
79
- "iban",
80
- "bic",
81
- ]);
82
-
83
- const PII_USER_OWNED_NAME_HINTS = new Set([
84
- "body",
85
- "text",
86
- "content",
87
- "message",
88
- "comment",
89
- "description",
90
- "note",
91
- "notes",
92
- ]);
93
-
94
- function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
95
- return relFromRepoRoot(sf.getFilePath(), roots);
96
- }
97
-
98
- function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
99
- const first = call.getArguments()[0];
100
- return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
101
- }
102
-
103
- // Subject annotations answering "is this PII" (kept in sync with the
104
- // author-facing PersonalAnnotations type, @cosmicdrift/kumiko-types
105
- // packages/types/src/fields.ts). `personal` (0.210.0) is the successor
106
- // to the four legacy fields below — every value counts as answered,
107
- // including `personal: false` (the framework requires a `reason` for
108
- // it). `find` is NOT a silencer: it resolves to lookupable/searchable/
109
- // sensitive, which never answered the PII question either. `encrypted`
110
- // isn't one either — the boot validator checks it only together with
111
- // `sensitive` (ciphertext-at-rest), never as a substitute.
112
- function isPiiSilencerAssignment(name: string, init: Node | undefined): boolean {
113
- if (name === "personal") {
114
- return (
115
- init !== undefined &&
116
- init.getKind() !== SyntaxKind.UndefinedKeyword &&
117
- !(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
118
- init.getKind() !== SyntaxKind.NullKeyword
119
- );
120
- }
121
- if (name === "allowPlaintext") return init?.isKind(SyntaxKind.StringLiteral) ?? false;
122
- if (name === "pii") return init?.getKind() === SyntaxKind.TrueKeyword;
123
- if (name === "tenantOwned") return init?.getKind() === SyntaxKind.TrueKeyword;
124
- if (name === "userOwned") return init?.isKind(SyntaxKind.ObjectLiteralExpression) ?? false;
125
- return false;
126
- }
127
-
128
- function objectHasPiiSilencer(obj: ObjectLiteralExpression): boolean {
129
- for (const prop of obj.getProperties()) {
130
- if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
131
- if (isPiiSilencerAssignment(prop.getName(), prop.getInitializer())) return true;
132
- }
133
- return false;
134
- }
135
-
136
- function enclosingFieldName(call: CallExpression): string | null {
137
- let node = call.getParent();
138
- while (node) {
139
- if (node.isKind(SyntaxKind.PropertyAssignment)) {
140
- return node.getName();
141
- }
142
- node = node.getParent();
143
- }
144
- return null;
145
- }
146
-
147
- export interface Finding {
148
- file: string;
149
- line: number;
150
- fieldName: string;
151
- hint: string;
152
- }
153
-
154
- function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
155
- const findings: Finding[] = [];
156
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
157
- const callee = call.getExpression().getText();
158
- if (callee !== "createTextField" && callee !== "createLongTextField") continue;
159
-
160
- const fieldName = enclosingFieldName(call);
161
- if (!fieldName) continue;
162
-
163
- const options = fieldFactoryOptions(call);
164
- if (options && objectHasPiiSilencer(options)) continue;
165
-
166
- const lower = fieldName.toLowerCase();
167
- let hint: string | null = null;
168
- if (PII_DIRECT_NAME_HINTS.has(lower)) {
169
- hint = `{ personal: ... } or { pii: true } or { allowPlaintext: "is-business-data" }`;
170
- } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
171
- hint = `{ personal: { of: "<authorIdField>" }, find: "exact" } or { userOwned: { ownerField: "<authorIdField>" } } or { allowPlaintext: "..." }`;
172
- }
173
- if (!hint) continue;
174
-
175
- const line = call.getStartLineNumber();
176
- const file = relFile(sf, roots);
177
- findings.push({ file, line, fieldName, hint });
178
- }
179
- return findings;
180
- }
181
-
182
- export function scan(
183
- files: readonly SourceFile[],
184
- roots: readonly RepoRoot[] = resolveRepoRoots(),
185
- ): {
186
- findings: Finding[];
187
- scanned: number;
188
- } {
189
- const findings: Finding[] = [];
190
- let scanned = 0;
191
- for (const sf of files) {
192
- if (EXCLUDE.test(sf.getFilePath())) continue;
193
- scanned++;
194
- findings.push(...scanFieldFactories(sf, roots));
195
- }
196
- return { findings, scanned };
197
- }
198
-
199
- function localFindings(findings: readonly Finding[]): readonly Finding[] {
200
- return findings.filter(isLocalFinding);
201
- }
202
-
203
- function countByFile(findings: readonly Finding[]): Record<string, number> {
204
- const counts: Record<string, number> = {};
205
- for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
206
- return counts;
207
- }
208
-
209
- const BASELINE_FILE = ".kumiko-pii-annotations-baseline.json";
210
- const piiBaseline = baselineRatchet({
211
- file: path.join(ROOT, BASELINE_FILE),
212
- formatVersion: 1,
213
- unit: "PII finding(s)",
214
- });
215
-
216
- // One place for "what goes into the baseline", used by both the compare and
217
- // the write path — a sibling finding leaking into the written file would turn
218
- // a foreign checkout's code into a local, unfixable red.
219
- export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
220
- return countByFile(localFindings(findings));
221
- }
222
-
223
- function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
224
- const local = localFindings(findings);
225
- const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
226
- return piiBaseline.check(
227
- baselineCounts(findings),
228
- `Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
229
- {
230
- formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"\` once.`,
231
- resolveLine,
232
- },
233
- );
234
- }
235
-
236
- function analyse(
237
- files: readonly SourceFile[],
238
- roots: readonly RepoRoot[],
239
- compareBaseline: boolean,
240
- ): GuardOutcome {
241
- const { findings } = scan(files, roots);
242
- if (!compareBaseline) {
243
- for (const f of findings)
244
- console.warn(
245
- ` [pii-annotations WARN] ${f.file}:${f.line} field "${f.fieldName}" looks like PII — mark ${f.hint}`,
246
- );
247
- console.log(" Baseline comparison skipped (--no-baseline).");
248
- return { violations: [] };
249
- }
250
- return { violations: checkBaseline(findings) };
251
- }
252
-
253
- export const guard: AstGuard = {
254
- name: "PII-Annotations Guard",
255
- scan: SCAN,
256
- hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"`',
257
- run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
258
- writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
259
- };
260
-
261
- // Flags are read ONLY here, not in run() — the shared runner
262
- // (run-guards.ts) drives all guards with the same argv, and a
263
- // --write-baseline there must not silently rewrite the
264
- // baseline.
265
- if (import.meta.main) {
266
- const args = process.argv.slice(2);
267
- if (args.includes("--write-baseline")) {
268
- const project = buildSharedProject([guard]);
269
- const { findings } = scan(filesForGuard(project, guard));
270
- piiBaseline.write(baselineCounts(findings));
271
- process.exit(0);
272
- }
273
- if (args.includes("--no-baseline")) {
274
- const project = buildSharedProject([guard]);
275
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
276
- process.exit(0);
277
- }
278
- runStandalone(guard);
279
- }
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: reports `ctx.unsafeAppendEvent` (also `ctx["unsafeAppendEvent"]` and destructuring) outside `migrations/`, `upcasters/` and tests.
4
- *
5
- * `appendEvent` is the typed default, `tryAppendEvent` the optimistic variant. The untyped
6
- * `unsafeAppendEvent` skips payload typing and is meant for migrations and upcasters, where the
7
- * event type is not knowable at compile time.
8
- *
9
- * Existing call sites are frozen per file in `.kumiko-unsafe-append-event-baseline.json`; a count
10
- * over the baseline fails, at or under passes. Without the file (consumer repos) the guard only
11
- * warns.
12
- *
13
- * Usage:
14
- * bun packages/guards/src/guard-unsafe-append-event.ts # compare to baseline
15
- * bun packages/guards/src/guard-unsafe-append-event.ts --write-baseline # freeze current
16
- */
17
-
18
- import * as path from "node:path";
19
-
20
- import { Node, type SourceFile, SyntaxKind } from "ts-morph";
21
- import {
22
- ALL_REPO_KINDS,
23
- type AstGuard,
24
- baselineRatchet,
25
- buildSharedProject,
26
- filesForGuard,
27
- isLocalFinding,
28
- relFromRepoRoot,
29
- runStandalone,
30
- type ScanSpec,
31
- } from "./_lib/guard-kit";
32
- import { resolveRepoRoots } from "./_lib/roots";
33
-
34
- const SCAN: ScanSpec = {
35
- kinds: ALL_REPO_KINDS,
36
- scope: "source",
37
- extensions: ["ts"],
38
- frameworkWithin: ["packages/*/src/**", "samples/**"],
39
- };
40
-
41
- const SYMBOL = "unsafeAppendEvent";
42
- const ALLOWED_PATH = /(^|\/)(migrations|upcasters|__tests__)\/|\.test\.ts$|\.d\.ts$/;
43
-
44
- function unsafeAppendEventLines(sf: SourceFile): number[] {
45
- const propertyAccesses = sf
46
- .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
47
- .filter((access) => access.getNameNode().getText() === SYMBOL);
48
- const elementAccesses = sf
49
- .getDescendantsOfKind(SyntaxKind.ElementAccessExpression)
50
- .filter((access) => {
51
- const argument = access.getArgumentExpression();
52
- return (
53
- argument !== undefined &&
54
- (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) &&
55
- argument.getLiteralText() === SYMBOL
56
- );
57
- });
58
- const destructurings = sf
59
- .getDescendantsOfKind(SyntaxKind.BindingElement)
60
- .filter(
61
- (element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === SYMBOL,
62
- );
63
- return [...propertyAccesses, ...elementAccesses, ...destructurings].map((node) =>
64
- node.getStartLineNumber(),
65
- );
66
- }
67
-
68
- type Finding = { readonly file: string; readonly line: number };
69
-
70
- function scan(files: readonly SourceFile[], roots = resolveRepoRoots()): Finding[] {
71
- const findings: Finding[] = [];
72
- for (const sf of files) {
73
- const file = relFromRepoRoot(sf.getFilePath(), roots);
74
- if (ALLOWED_PATH.test(file)) continue;
75
- for (const line of unsafeAppendEventLines(sf)) findings.push({ file, line });
76
- }
77
- return findings;
78
- }
79
-
80
- function countsPerFile(findings: readonly Finding[]): Record<string, number> {
81
- const counts: Record<string, number> = {};
82
- for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
83
- return counts;
84
- }
85
-
86
- const GUARD_NAME = "Unsafe-Append-Event Guard";
87
- const DEFAULT_BASELINE_FILE = path.join(process.cwd(), ".kumiko-unsafe-append-event-baseline.json");
88
-
89
- const REMEDIATION =
90
- "unsafeAppendEvent drops payload typing. Use ctx.appendEvent (default) or ctx.tryAppendEvent (conflict as result); the untyped variant belongs in migrations/ and upcasters/.";
91
-
92
- const REWRITE_COMMAND = `kumiko-guards guards --write-baseline --guard="${GUARD_NAME}"`;
93
-
94
- export function createUnsafeAppendEventGuard(baselineFile: string): AstGuard {
95
- const baseline = baselineRatchet({
96
- file: baselineFile,
97
- formatVersion: 1,
98
- unit: "unsafeAppendEvent call(s)",
99
- });
100
- return {
101
- name: GUARD_NAME,
102
- scan: SCAN,
103
- hint: `${REMEDIATION} After a deliberate change: \`${REWRITE_COMMAND}\`.`,
104
- run(files, roots = resolveRepoRoots()) {
105
- const findings = scan(files, roots);
106
- const violations = baseline.check(countsPerFile(findings), REMEDIATION, {
107
- formatDriftRemediation: `Run \`${REWRITE_COMMAND}\` once.`,
108
- resolveLine: (file) => findings.find((f) => f.file === file)?.line ?? 1,
109
- });
110
- return { violations };
111
- },
112
- writeBaseline: (files, roots) => baseline.write(countsPerFile(scan(files, roots))),
113
- };
114
- }
115
-
116
- export const guard = createUnsafeAppendEventGuard(DEFAULT_BASELINE_FILE);
117
-
118
- if (import.meta.main) {
119
- if (process.argv.slice(2).includes("--write-baseline")) {
120
- const project = buildSharedProject([guard]);
121
- guard.writeBaseline?.(filesForGuard(project, guard));
122
- process.exit(0);
123
- }
124
- runStandalone(guard);
125
- }