@cosmicdrift/kumiko-guards 0.356.0 → 0.357.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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/bun-lock-packages.ts +5 -5
  3. package/src/_lib/guard-kit.ts +180 -131
  4. package/src/_lib/qn.ts +9 -9
  5. package/src/_lib/roots.ts +4 -3
  6. package/src/_lib/security-baseline.ts +193 -116
  7. package/src/_lib/sql-inventory.ts +34 -32
  8. package/src/changes.json +6 -0
  9. package/src/check-as-casts.ts +196 -180
  10. package/src/check-complexity.ts +68 -69
  11. package/src/check-predicates.ts +93 -67
  12. package/src/check-secret-literals.ts +49 -38
  13. package/src/check-single-runtime-instance.ts +1 -1
  14. package/src/cli.ts +30 -17
  15. package/src/guard-access-denied-test.ts +49 -23
  16. package/src/guard-app-feature-structure.ts +92 -73
  17. package/src/guard-broker-subscribe.ts +17 -17
  18. package/src/guard-comment-lang.ts +126 -54
  19. package/src/guard-direct-entity-writes.ts +58 -45
  20. package/src/guard-escape-hatch-declared.ts +26 -23
  21. package/src/guard-html-escape.ts +112 -86
  22. package/src/guard-i18n-keys.ts +39 -36
  23. package/src/guard-i18n-locale-mount.ts +47 -40
  24. package/src/guard-i18n-ui-strings.ts +108 -92
  25. package/src/guard-lib-test-coverage.ts +37 -37
  26. package/src/guard-loadall-events.ts +17 -17
  27. package/src/guard-no-framed-extension-sections.ts +4 -4
  28. package/src/guard-no-temporal-polyfill-import.ts +19 -1
  29. package/src/guard-pii-annotations.ts +16 -16
  30. package/src/guard-primitives-discipline.ts +61 -65
  31. package/src/guard-screen-conventions.ts +2 -0
  32. package/src/guard-table-ddl.ts +56 -62
  33. package/src/guard-tenant-escalation.ts +42 -34
  34. package/src/guard-test-stack-drift.ts +6 -6
  35. package/src/guard-test-timeouts.ts +5 -4
  36. package/src/guard-text-field-stance.ts +22 -22
  37. package/src/guard-thin-wrappers.ts +42 -45
  38. package/src/guard-write-handler-qns.ts +29 -29
  39. package/src/runtime-isolation-classify.ts +4 -3
@@ -1,43 +1,43 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * `as X` Cast Audit mit Baseline-Regression-Report (WARNUNG, kein Fail).
4
+ * `as X` cast audit with a baseline regression report (WARNING, no fail).
5
5
  *
6
- * Jeder `as X`-Cast ist ein Compiler-Knebel. Dieser Check zeigt alle Casts
7
- * im Production Code, kategorisiert in:
6
+ * Every `as X` cast is a compiler gag. This check shows all casts
7
+ * in production code, categorized as:
8
8
  *
9
- * • legit-const — `x as const` (Literal-widening verhindern)
10
- * • legit-brand — `"..." as BrandedType` (Branded-Type-Konstruktion)
11
- * • legit-bridge — `x as unknown as Y` (bewusster Double-Cast)
12
- * • legit-boundary — Cast an System-Grenze, markiert mit `// @cast-boundary <reason>`
13
- * (Pipeline-payload, JSON-from-DB, Zod-Issue, Hook-Context)
14
- * • suspect-parse — cast direkt nach JSON.parse / .parseJsonSafe (externer Input)
15
- * • suspect-narrow — cast einer Variable zur Union-Verengung
16
- * • suspect-general — alles andere (TypeGuard- oder Typing-Kandidat)
9
+ * • legit-const — `x as const` (prevents literal widening)
10
+ * • legit-brand — `"..." as BrandedType` (branded-type construction)
11
+ * • legit-bridge — `x as unknown as Y` (deliberate double cast)
12
+ * • legit-boundary — cast at a system boundary, marked with `// @cast-boundary <reason>`
13
+ * (pipeline payload, JSON-from-DB, Zod issue, hook context)
14
+ * • suspect-parse — cast directly after JSON.parse / .parseJsonSafe (external input)
15
+ * • suspect-narrow — cast of a variable for union narrowing
16
+ * • suspect-general — everything else (type guard or typing candidate)
17
17
  *
18
- * Die ersten vier sind legitim. Die letzten drei sind Refactor-Kandidaten.
18
+ * The first four are legitimate. The last three are refactoring candidates.
19
19
  *
20
- * Boundary-Marker setzen wenn ein Cast inhärent an einer System-Grenze
21
- * sitzt (z.B. dispatch-payload ist generic über alle Entity-Types und
22
- * kann nicht weiter typisiert werden). Reason im Kommentar erklärt warum:
20
+ * Set a boundary marker when a cast inherently sits at a system boundary
21
+ * (e.g. the dispatch payload is generic over all entity types and
22
+ * cannot be typed further). The reason in the comment explains why:
23
23
  *
24
- * // @cast-boundary engine-payload — generic dispatch-Result über alle Entities
24
+ * // @cast-boundary engine-payload — generic dispatch result over all entities
25
25
  * const data = result.data as Record<string, unknown>;
26
26
  *
27
- * Baseline-Regression-Report:
27
+ * Baseline regression report:
28
28
  *
29
- * `.kumiko-cast-baseline.json` im Repo-Root pinnt pro File die expected
30
- * suspect-Cast-Anzahl. Der Audit vergleicht gegen die Baseline und meldet
31
- * Zuwaechse als Report — laut Projekt-Coding-Standards ("Type Assertions")
32
- * ist dieser Check "Warnung, kein Fail": er blockt nie, auch nicht bei
33
- * unbekannten @cast-boundary-Reasons oder Baseline-Regression.
34
- * Reduktionen (aktuell < baseline) sind erlaubt aber updaten die Baseline
35
- * NICHT automatisch — nach Cleanup-Commits `--write-baseline` aufrufen.
29
+ * `.kumiko-cast-baseline.json` in the repo root pins the expected
30
+ * suspect-cast count per file. The audit compares against the baseline and
31
+ * reports increases — per the project coding standards ("Type Assertions")
32
+ * this check is "warning, no fail": it never blocks, not even on
33
+ * unknown @cast-boundary reasons or a baseline regression.
34
+ * Reductions (current < baseline) are allowed but do NOT update the baseline
35
+ * automatically — run `--write-baseline` after cleanup commits.
36
36
  *
37
37
  * Usage:
38
- * bun guards/check-as-casts.ts # Vergleich gegen Baseline
39
- * bun guards/check-as-casts.ts --write-baseline # Baseline neu schreiben
40
- * bun guards/check-as-casts.ts --no-baseline # Vergleich überspringen
38
+ * bun guards/check-as-casts.ts # compare against baseline
39
+ * bun guards/check-as-casts.ts --write-baseline # rewrite baseline
40
+ * bun guards/check-as-casts.ts --no-baseline # skip comparison
41
41
  */
42
42
 
43
43
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -79,8 +79,8 @@ interface Site {
79
79
  source: string; // source expression (left of `as`)
80
80
  target: string; // target type (right of `as`)
81
81
  full: string;
82
- /** boundary-reason wenn der Cast einen `@cast-boundary <reason>`-Marker
83
- * hat. Nur relevant wenn category === "legit-boundary". */
82
+ /** boundary reason when the cast carries a `@cast-boundary <reason>` marker.
83
+ * Only relevant when category === "legit-boundary". */
84
84
  boundaryReason?: string;
85
85
  }
86
86
 
@@ -167,10 +167,10 @@ export function looksLikeBrandConstruction(cast: AsExpression): boolean {
167
167
  return false;
168
168
  }
169
169
 
170
- // Is the cast directly applied to a parse-Call result? Whitelist konkreter
171
- // Parse-Functions plus Zod's typische `*Schema.parse()` / `*Schema.safeParse()`
172
- // — `*.parse()` blanket-match wäre zu liberal (jeder eigene helper der
173
- // `.parse` heißt würde matchen, z.B. `myArray.parse()`).
170
+ // Is the cast directly applied to a parse-call result? Whitelist of concrete
171
+ // parse functions plus Zod's typical `*Schema.parse()` / `*Schema.safeParse()`
172
+ // — a blanket `*.parse()` match would be too liberal (any custom helper
173
+ // named `.parse` would match, e.g. `myArray.parse()`).
174
174
  const PARSE_CALL_RE =
175
175
  /^(?:JSON\.parse|parseJsonSafe|parseJsonOrThrow|\w*[Ss]chema\.(?:parse|safeParse))$/;
176
176
 
@@ -188,17 +188,16 @@ export function isNarrowingCast(cast: AsExpression): boolean {
188
188
  return cast.getExpression().getKind() === SyntaxKind.Identifier;
189
189
  }
190
190
 
191
- // Marker-Kommentar `// @cast-boundary <reason>` der den Cast als
192
- // bewusste System-Grenze markiert. Statt String-Line-Matching nutzen
193
- // wir den enclosing-Statement-Range mit Leading- und Trailing-Trivia
194
- // — deckt leading-Block-Comments, trailing-Line-Comments, inline-
195
- // Comments in multi-line Casts und mehrere Casts im selben Statement.
191
+ // Marker comment `// @cast-boundary <reason>` that marks the cast as a
192
+ // deliberate system boundary. Instead of string line matching we use
193
+ // the enclosing statement range with leading and trailing trivia
194
+ // — covers leading block comments, trailing line comments, inline
195
+ // comments in multi-line casts and multiple casts in the same statement.
196
196
  const BOUNDARY_MARKER_RE = /\/[/*]\s*@cast-boundary(?:\s+([\w-]+))?/;
197
-
198
- // Whitelist anerkannter Reasons. Neue Reason erfordert Eintrag hier —
199
- // verhindert Drift wie "engine-payload" / "engine_payload" /
200
- // "engine payload" parallel im Repo. Audit zeigt Warning bei unbekannten
201
- // Reasons; bei Bedarf erweitern + ggf. Konsumenten umbenennen.
197
+ // Whitelist of recognized reasons. A new reason requires an entry here —
198
+ // prevents drift like "engine-payload" / "engine_payload" /
199
+ // "engine payload" side by side in the repo. The audit warns on unknown
200
+ // reasons; extend if needed and rename consumers if applicable.
202
201
  export const KNOWN_BOUNDARY_REASONS = [
203
202
  // Pipeline / Engine
204
203
  "engine-payload", // dispatch-Result, event.payload, Hook-Context drilling
@@ -243,15 +242,24 @@ export function hasBoundaryMarker(cast: AsExpression): boolean {
243
242
  // whitespace-separated token after `@cast-boundary` (z.B. "engine-
244
243
  // payload"). Empty string when marker has no reason.
245
244
  //
246
- // Scope der Marker-Suche (Reihenfolge: most-specific zuerst):
247
- // 1. Leading-Comments die laut TS-Compiler dem Statement gehören
248
- // (`getLeadingCommentRanges` — kein Drift in vorhergehende Zeilen)
249
- // 2. Cast-Range selbst (multi-line casts mit inline-comment)
250
- // 3. Same-line trailing-comment am Cast-Ende (nicht weiter — würde
251
- // den nächsten Statement-Comment fälschlich claimen)
245
+ // Scope of the marker search (order: most specific first):
246
+ // 1. Leading comments that the TS compiler assigns to the statement
247
+ // (`getLeadingCommentRanges` — no drift into preceding lines)
248
+ // 2. The cast range itself (multi-line casts with an inline comment)
249
+ // 3. Same-line trailing comment at the cast end (no further — would
250
+ // wrongly claim the next statement's comment)
251
+ function boundaryReasonIn(text: string): string | null {
252
+ const match = BOUNDARY_MARKER_RE.exec(text);
253
+ return match ? (match[1] ?? "") : null;
254
+ }
255
+
256
+ function restOfLine(fullText: string, from: number): string {
257
+ const eolPos = fullText.indexOf("\n", from);
258
+ return fullText.slice(from, eolPos === -1 ? fullText.length : eolPos);
259
+ }
260
+
252
261
  export function extractBoundaryReason(cast: AsExpression): string | null {
253
- const sf = cast.getSourceFile();
254
- const fullText = sf.getFullText();
262
+ const fullText = cast.getSourceFile().getFullText();
255
263
 
256
264
  // (1) Leading comments des enclosing statement
257
265
  const stmt =
@@ -259,74 +267,61 @@ export function extractBoundaryReason(cast: AsExpression): string | null {
259
267
  cast.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ??
260
268
  cast.getFirstAncestorByKind(SyntaxKind.ReturnStatement) ??
261
269
  cast.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
262
- const leadingRanges = stmt?.getLeadingCommentRanges() ?? [];
263
- for (const r of leadingRanges) {
264
- const m = BOUNDARY_MARKER_RE.exec(r.getText());
265
- if (m) return m[1] ?? "";
270
+ for (const r of stmt?.getLeadingCommentRanges() ?? []) {
271
+ const reason = boundaryReasonIn(r.getText());
272
+ if (reason !== null) return reason;
266
273
  }
267
274
 
268
275
  // (2) Cast-Range selbst (multi-line casts, inline comments innerhalb)
269
- const castRange = fullText.slice(cast.getStart(), cast.getEnd());
270
- const inlineMatch = BOUNDARY_MARKER_RE.exec(castRange);
271
- if (inlineMatch) return inlineMatch[1] ?? "";
272
-
273
- // (3) Same-line trailing comment am Cast-Ende. Wir gehen vom Cast-End
274
- // zum nächsten EOL — das umfasst nur comments auf der Cast-Zeile, nicht
275
- // Folge-Statement-Comments.
276
- const castEnd = cast.getEnd();
277
- const eolPos = fullText.indexOf("\n", castEnd);
278
- const trailingRange = fullText.slice(castEnd, eolPos === -1 ? fullText.length : eolPos);
279
- const trailingMatch = BOUNDARY_MARKER_RE.exec(trailingRange);
280
- if (trailingMatch) return trailingMatch[1] ?? "";
281
-
282
- // (4) Same-line trailing comment am Statement-Ende (für Casts die nicht
283
- // selbst am Statement-Ende stehen — z.B. innerhalb einer Function-Call
284
- // Argument-Liste). Suche von Statement-End bis EOL.
285
- if (stmt) {
286
- const stmtEnd = stmt.getEnd();
287
- const stmtEol = fullText.indexOf("\n", stmtEnd);
288
- const stmtTrailingRange = fullText.slice(stmtEnd, stmtEol === -1 ? fullText.length : stmtEol);
289
- const stmtTrailingMatch = BOUNDARY_MARKER_RE.exec(stmtTrailingRange);
290
- if (stmtTrailingMatch) return stmtTrailingMatch[1] ?? "";
291
- }
292
-
293
- return null;
276
+ const inlineReason = boundaryReasonIn(fullText.slice(cast.getStart(), cast.getEnd()));
277
+ if (inlineReason !== null) return inlineReason;
278
+
279
+ // (3) Same-line trailing comment at the cast end. We go from the cast end
280
+ // to the next EOL — this covers only comments on the cast line, not
281
+ // comments of following statements.
282
+ const trailingReason = boundaryReasonIn(restOfLine(fullText, cast.getEnd()));
283
+ if (trailingReason !== null) return trailingReason;
284
+
285
+ // (4) Same-line trailing comment at the statement end (for casts that are
286
+ // not at the statement end themselves — e.g. inside a function-call
287
+ // argument list). Search from statement end to EOL.
288
+ return stmt ? boundaryReasonIn(restOfLine(fullText, stmt.getEnd())) : null;
294
289
  }
295
290
 
296
- // Type-Names die per Definition typing-loss-marker sind — siehe
291
+ // Type names that are typing-loss markers by definition — see
297
292
  // `packages/framework/src/db/connection.ts` (DbRow = Record<string, unknown>
298
- // als bewusster Marker an der Drizzle-Boundary). Cast zu solchen Types
299
- // IST der Marker — separater `@cast-boundary db-row`-Kommentar wäre
300
- // redundant. Liste klein halten: nur Types die im Comment ausdrücklich
301
- // als typing-loss-marker dokumentiert sind.
293
+ // as a deliberate marker at the Drizzle boundary). A cast to such types
294
+ // IS the marker — a separate `@cast-boundary db-row` comment would be
295
+ // redundant. Keep the list small: only types explicitly documented in a
296
+ // comment as a typing-loss marker.
302
297
  const TYPING_LOSS_MARKER_TYPES = new Set(["DbRow", "DbRow | undefined"]);
303
298
 
304
299
  export function isTypingLossMarkerCast(cast: AsExpression): boolean {
305
300
  return TYPING_LOSS_MARKER_TYPES.has(cast.getTypeNode()?.getText() ?? "");
306
301
  }
307
302
 
308
- // File-Default-Reasons: Verzeichnisse die per Konvention nur eine
309
- // Sorte boundary-cast enthalten. Statt jeden Cast einzeln mit
310
- // `@cast-boundary <reason>` zu markieren, gibt der Pfad-Match den
311
- // Reason vor. Per-Cast-Marker übersteuert diesen Default trotzdem.
312
- // Konvention beibehalten: nur Pfade die WIRKLICH einheitlich sind
313
- // (nicht "fast einheitlich" — Drift-Gefahr).
303
+ // File-default reasons: directories that by convention contain only one
304
+ // kind of boundary cast. Instead of marking every cast individually with
305
+ // `@cast-boundary <reason>`, the path match supplies the reason. A
306
+ // per-cast marker still overrides this default.
307
+ // Keep the convention: only paths that are REALLY uniform
308
+ // (not "almost uniform" — drift risk).
314
309
  const FILE_DEFAULT_REASONS: ReadonlyArray<{
315
310
  pattern: RegExp;
316
- reason: BoundaryReason;
311
+ boundaryReason: BoundaryReason;
317
312
  }> = [
318
313
  {
319
314
  // feature-AST extractors: alle Casts gehen vom erased ts-morph-Parse-
320
315
  // Result zu typed feature-Definitionen (EntityDefinition, RelationDef,
321
316
  // NavDef, ConfigKeys, …). Per Konstruktion ein schema-walk.
322
317
  pattern: /\/engine\/feature-ast\//,
323
- reason: "schema-walk",
318
+ boundaryReason: "schema-walk",
324
319
  },
325
320
  ];
326
321
 
327
322
  export function getFileDefaultReason(filePath: string): BoundaryReason | null {
328
323
  for (const entry of FILE_DEFAULT_REASONS) {
329
- if (entry.pattern.test(filePath)) return entry.reason;
324
+ if (entry.pattern.test(filePath)) return entry.boundaryReason;
330
325
  }
331
326
  return null;
332
327
  }
@@ -351,10 +346,10 @@ function collect(sf: SourceFile): Site[] {
351
346
  // report only the inner (the `as unknown`) so each bridge shows once.
352
347
  if (isBridgeOuter(cast)) continue;
353
348
  const category = categorize(cast);
354
- // TypingLossMarker-Casts (z.B. `as DbRow`) und FileDefault-Casts
355
- // (z.B. feature-ast/extractors.ts → "schema-walk") sind per Type-
356
- // Definition / Konvention boundary — synthetic reason damit der
357
- // unknown-reason-Check nicht fault wirft.
349
+ // TypingLossMarker casts (e.g. `as DbRow`) and FileDefault casts
350
+ // (e.g. feature-ast/extractors.ts → "schema-walk") are boundaries by type
351
+ // definition / convention — synthetic reason so the unknown-reason check
352
+ // does not fail on them.
358
353
  const reason =
359
354
  category === "legit-boundary"
360
355
  ? (extractBoundaryReason(cast) ??
@@ -410,18 +405,11 @@ function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
410
405
  // run of this file prints them.
411
406
  const PRINT_SITE_LISTINGS = import.meta.main;
412
407
 
413
- function reportCasts(all: readonly Site[], scanned: number): void {
414
- const byCat = groupByCategory(all);
415
- console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
416
- for (const c of CATS) {
417
- const count = byCat.get(c)?.length ?? 0;
418
- console.log(` ${c.padEnd(18)} ${count}`);
419
- }
420
- // skip: site listings are printed only on a standalone run of this guard
421
- if (!PRINT_SITE_LISTINGS) return;
422
-
423
- const suspects = CATS.filter((c) => c.startsWith("suspect-"));
408
+ function castSnippet(site: Site): string {
409
+ return site.full.length > 90 ? `${site.full.slice(0, 87)}...` : site.full;
410
+ }
424
411
 
412
+ function printSuspectTopTargets(all: readonly Site[]): void {
425
413
  // Aggregate suspect casts by target type — reveals bulk-refactor patterns
426
414
  // (e.g. "20x `as Record<string, unknown>`" → one DB-row helper fixes all).
427
415
  console.log("\n Suspect-cast top targets (>=3):");
@@ -438,37 +426,52 @@ function reportCasts(all: readonly Site[], scanned: number): void {
438
426
  for (const [target, sites] of topTargets) {
439
427
  console.log(` ${sites.length}x as ${target}`);
440
428
  }
429
+ }
441
430
 
442
- for (const c of suspects) {
443
- const sites = byCat.get(c);
444
- if (!sites || sites.length === 0) continue;
445
- if (c === "suspect-general" && sites.length > 30) {
446
- console.log(`\n ${c} (${sites.length}, showing top 30 by file):`);
447
- const sorted = [...sites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
448
- for (const s of sorted.slice(0, 30)) {
449
- const snippet = s.full.length > 90 ? `${s.full.slice(0, 87)}...` : s.full;
450
- console.log(` ${s.file}:${s.line} ${snippet}`);
451
- }
452
- console.log(` ... (${sites.length - 30} more)`);
453
- continue;
431
+ function printSuspectCategory(category: Category, sites: readonly Site[]): void {
432
+ if (category === "suspect-general" && sites.length > 30) {
433
+ console.log(`\n ${category} (${sites.length}, showing top 30 by file):`);
434
+ const sorted = [...sites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
435
+ for (const s of sorted.slice(0, 30)) {
436
+ console.log(` ${s.file}:${s.line} ${castSnippet(s)}`);
454
437
  }
455
- console.log(`\n ${c} (${sites.length}):`);
438
+ console.log(` ... (${sites.length - 30} more)`);
439
+ } else {
440
+ console.log(`\n ${category} (${sites.length}):`);
456
441
  for (const s of sites) {
457
- const snippet = s.full.length > 90 ? `${s.full.slice(0, 87)}...` : s.full;
458
- console.log(` ${s.file}:${s.line} ${snippet}`);
442
+ console.log(` ${s.file}:${s.line} ${castSnippet(s)}`);
459
443
  }
460
444
  }
445
+ }
446
+
447
+ function reportCasts(all: readonly Site[], scanned: number): void {
448
+ const byCat = groupByCategory(all);
449
+ console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
450
+ for (const c of CATS) {
451
+ const count = byCat.get(c)?.length ?? 0;
452
+ console.log(` ${c.padEnd(18)} ${count}`);
453
+ }
454
+ // skip: site listings are printed only on a standalone run of this guard
455
+ if (!PRINT_SITE_LISTINGS) return;
456
+
457
+ printSuspectTopTargets(all);
458
+
459
+ for (const c of CATS.filter((cat) => cat.startsWith("suspect-"))) {
460
+ const sites = byCat.get(c);
461
+ if (!sites || sites.length === 0) continue;
462
+ printSuspectCategory(c, sites);
463
+ }
461
464
 
462
465
  console.log(
463
466
  "\n Rule: every suspect cast is a candidate for a TypeGuard, Discriminated Union, or better typing at the source.",
464
467
  );
465
468
  }
466
469
 
467
- // Reason-Validation: jeder legit-boundary-Cast sollte einen bekannten Reason
468
- // haben. Unbekannte Reasons werden gemeldet (Reason-Drift wie „engine-
469
- // payload" vs „enginePayload" zerstört sonst langfristig die Audit-
470
- // Aussagekraft) — Whitelist-Erweiterung: in KNOWN_BOUNDARY_REASONS oben
471
- // eintragen. Warnung, kein Fail (siehe Modul-Header).
470
+ // Reason validation: every legit-boundary cast should have a known reason.
471
+ // Unknown reasons are reported (reason drift like "engine-payload" vs
472
+ // "enginePayload" would otherwise destroy the audit's value in the long
473
+ // run) — extending the whitelist: add an entry to KNOWN_BOUNDARY_REASONS
474
+ // above. Warning, no fail (see module header).
472
475
  function reportUnknownReasons(all: readonly Site[]): void {
473
476
  const unknownReasons: Array<{ file: string; line: number; reason: string }> = [];
474
477
  for (const s of all) {
@@ -478,13 +481,14 @@ function reportUnknownReasons(all: readonly Site[]): void {
478
481
  unknownReasons.push({ file: s.file, line: s.line, reason: r || "<missing>" });
479
482
  }
480
483
  }
481
- if (unknownReasons.length === 0) return;
482
- console.log(`\n UNKNOWN @cast-boundary REASONS (${unknownReasons.length}):`);
483
- for (const u of unknownReasons) {
484
- console.log(` ${u.file}:${u.line} reason=${u.reason}`);
484
+ if (unknownReasons.length > 0) {
485
+ console.log(`\n UNKNOWN @cast-boundary REASONS (${unknownReasons.length}):`);
486
+ for (const u of unknownReasons) {
487
+ console.log(` ${u.file}:${u.line} reason=${u.reason}`);
488
+ }
489
+ console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
490
+ console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
485
491
  }
486
- console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
487
- console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
488
492
  }
489
493
 
490
494
  const BASELINE_FORMAT_VERSION = 2;
@@ -535,7 +539,7 @@ function writeBaseline(all: readonly Site[]): void {
535
539
  const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
536
540
  const payload: Baseline = {
537
541
  format: BASELINE_FORMAT_VERSION,
538
- generated: new Date().toISOString().slice(0, 10),
542
+ generated: Temporal.Now.instant().toString().slice(0, 10),
539
543
  totalSuspect: totalOf(repoLocalSuspects),
540
544
  perFile: Object.fromEntries(
541
545
  Object.entries(repoLocalSuspects)
@@ -552,8 +556,8 @@ function writeBaseline(all: readonly Site[]): void {
552
556
  }
553
557
 
554
558
  // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
555
- // Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
556
- // obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
559
+ // A cast swap (cast A removed, cast B with a different target added) is detected
560
+ // this way although the total stays stable. Report only — warning, no fail.
557
561
  // Merge-conflict markers, truncated writes and hand edits all land here; the
558
562
  // file is read once at the boundary so the comparison below can trust its shape.
559
563
  function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
@@ -570,7 +574,7 @@ function isCountsByFile(value: unknown): value is Record<string, Record<string,
570
574
  type BaselineLoad =
571
575
  | { kind: "missing" }
572
576
  | { kind: "format-drift"; format: unknown }
573
- | { kind: "invalid"; reason: string }
577
+ | { kind: "invalid"; detail: string }
574
578
  | { kind: "ok"; baseline: Baseline };
575
579
 
576
580
  export function loadBaseline(file: string): BaselineLoad {
@@ -579,10 +583,10 @@ export function loadBaseline(file: string): BaselineLoad {
579
583
  try {
580
584
  parsed = JSON.parse(readFileSync(file, "utf-8"));
581
585
  } catch (error) {
582
- return { kind: "invalid", reason: error instanceof Error ? error.message : String(error) };
586
+ return { kind: "invalid", detail: error instanceof Error ? error.message : String(error) };
583
587
  }
584
588
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
585
- return { kind: "invalid", reason: "expected a JSON object" };
589
+ return { kind: "invalid", detail: "expected a JSON object" };
586
590
  }
587
591
  const record: Record<string, unknown> = { ...parsed };
588
592
  if (record["format"] !== BASELINE_FORMAT_VERSION) {
@@ -596,7 +600,7 @@ export function loadBaseline(file: string): BaselineLoad {
596
600
  ) {
597
601
  return {
598
602
  kind: "invalid",
599
- reason: "expected { format, generated: string, totalSuspect: number, perFile: object }",
603
+ detail: "expected { format, generated: string, totalSuspect: number, perFile: object }",
600
604
  };
601
605
  }
602
606
  return {
@@ -605,6 +609,50 @@ export function loadBaseline(file: string): BaselineLoad {
605
609
  };
606
610
  }
607
611
 
612
+ type CastRegression = { file: string; target: string; baseline: number; current: number };
613
+
614
+ function compareCastsToBaseline(
615
+ current: Record<string, Record<string, number>>,
616
+ baselinePerFile: Record<string, Record<string, number>>,
617
+ ): { regressions: CastRegression[]; reduced: number } {
618
+ const regressions: CastRegression[] = [];
619
+ let reduced = 0;
620
+ const allFiles = new Set([...Object.keys(current), ...Object.keys(baselinePerFile)]);
621
+ for (const file of allFiles) {
622
+ const baselineTargets = baselinePerFile[file] ?? {};
623
+ const currentTargets = current[file] ?? {};
624
+ const allTargets = new Set([...Object.keys(baselineTargets), ...Object.keys(currentTargets)]);
625
+ for (const target of allTargets) {
626
+ const expected = baselineTargets[target] ?? 0;
627
+ const count = currentTargets[target] ?? 0;
628
+ if (count > expected) {
629
+ regressions.push({ file, target, baseline: expected, current: count });
630
+ } else if (count < expected) {
631
+ reduced += expected - count;
632
+ }
633
+ }
634
+ }
635
+ return { regressions, reduced };
636
+ }
637
+
638
+ function printCastRegressions(regressions: readonly CastRegression[]): void {
639
+ console.log(
640
+ `\n REGRESSION: ${regressions.length} (file, target) pair(s) have more suspect casts than the baseline:`,
641
+ );
642
+ for (const r of regressions) {
643
+ console.log(
644
+ ` ${r.file}: as ${r.target} baseline=${r.baseline} current=${r.current} (+${r.current - r.baseline})`,
645
+ );
646
+ }
647
+ console.log(
648
+ "\n New casts need a justification. Options:\n" +
649
+ " 1. Avoid the cast (TypeGuard, Discriminated Union, better typing at the source)\n" +
650
+ " 2. If a legit system boundary: add a `// @cast-boundary <reason>` marker\n" +
651
+ " 3. If a cleanup reduction in one file offsets an increase in another: " +
652
+ "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
653
+ );
654
+ }
655
+
608
656
  export function reportBaseline(
609
657
  all: readonly Site[],
610
658
  baselineFile: string = baselinePath,
@@ -625,7 +673,7 @@ export function reportBaseline(
625
673
  {
626
674
  file: baselineFile,
627
675
  line: 1,
628
- message: `Cannot read cast baseline ${baselineFile}: ${loaded.reason}. Fix the file (e.g. resolve merge-conflict markers) or regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
676
+ message: `Cannot read cast baseline ${baselineFile}: ${loaded.detail}. Fix the file (e.g. resolve merge-conflict markers) or regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
629
677
  },
630
678
  ];
631
679
  }
@@ -638,41 +686,9 @@ export function reportBaseline(
638
686
  }
639
687
  const { baseline } = loaded;
640
688
 
641
- type Regression = { file: string; target: string; baseline: number; current: number };
642
- const regressions: Regression[] = [];
643
- let reduced = 0;
644
- const allFiles = new Set([...Object.keys(repoLocalSuspects), ...Object.keys(baseline.perFile)]);
645
- for (const file of allFiles) {
646
- const baselineTargets = baseline.perFile[file] ?? {};
647
- const currentTargets = repoLocalSuspects[file] ?? {};
648
- const allTargets = new Set([...Object.keys(baselineTargets), ...Object.keys(currentTargets)]);
649
- for (const target of allTargets) {
650
- const expected = baselineTargets[target] ?? 0;
651
- const current = currentTargets[target] ?? 0;
652
- if (current > expected) {
653
- regressions.push({ file, target, baseline: expected, current });
654
- } else if (current < expected) {
655
- reduced += expected - current;
656
- }
657
- }
658
- }
659
-
689
+ const { regressions, reduced } = compareCastsToBaseline(repoLocalSuspects, baseline.perFile);
660
690
  if (regressions.length > 0) {
661
- console.log(
662
- `\n REGRESSION: ${regressions.length} (file, target) pair(s) have more suspect casts than the baseline:`,
663
- );
664
- for (const r of regressions) {
665
- console.log(
666
- ` ${r.file}: as ${r.target} baseline=${r.baseline} current=${r.current} (+${r.current - r.baseline})`,
667
- );
668
- }
669
- console.log(
670
- "\n New casts need a justification. Options:\n" +
671
- " 1. Avoid the cast (TypeGuard, Discriminated Union, better typing at the source)\n" +
672
- " 2. If a legit system boundary: add a `// @cast-boundary <reason>` marker\n" +
673
- " 3. If a cleanup reduction in one file offsets an increase in another: " +
674
- "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
675
- );
691
+ printCastRegressions(regressions);
676
692
  return [];
677
693
  }
678
694
 
@@ -693,9 +709,9 @@ function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): G
693
709
  console.log("\n Baseline comparison skipped (--no-baseline).");
694
710
  return { violations: [] };
695
711
  }
696
- // WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
697
- // unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken. Nur
698
- // eine unlesbare Baseline schlägt fehl.
712
+ // WARNING, no fail (coding-standards.md → "Type Assertions"): neither
713
+ // unknown @cast-boundary reasons nor a baseline regression block. Only
714
+ // an unreadable baseline fails.
699
715
  return { violations: reportBaseline(all) };
700
716
  }
701
717