@cosmicdrift/kumiko-guards 0.1.1 → 0.3.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 +4 -1
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +32 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `as X` Cast Audit mit Baseline-Regression-Report (WARNUNG, kein Fail).
|
|
5
|
+
*
|
|
6
|
+
* Jeder `as X`-Cast ist ein Compiler-Knebel. Dieser Check zeigt alle Casts
|
|
7
|
+
* im Production Code, kategorisiert in:
|
|
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)
|
|
17
|
+
*
|
|
18
|
+
* Die ersten vier sind legitim. Die letzten drei sind Refactor-Kandidaten.
|
|
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:
|
|
23
|
+
*
|
|
24
|
+
* // @cast-boundary engine-payload — generic dispatch-Result über alle Entities
|
|
25
|
+
* const data = result.data as Record<string, unknown>;
|
|
26
|
+
*
|
|
27
|
+
* Baseline-Regression-Report:
|
|
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.
|
|
36
|
+
*
|
|
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
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
44
|
+
import * as path from "node:path";
|
|
45
|
+
import { type AsExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
46
|
+
import {
|
|
47
|
+
type AstGuard,
|
|
48
|
+
buildSharedProject,
|
|
49
|
+
filesForGuard,
|
|
50
|
+
type GuardOutcome,
|
|
51
|
+
runStandalone,
|
|
52
|
+
type ScanSpec,
|
|
53
|
+
} from "./_lib/guard-kit";
|
|
54
|
+
|
|
55
|
+
const ROOT = process.cwd();
|
|
56
|
+
|
|
57
|
+
const SCAN: ScanSpec = {
|
|
58
|
+
scope: "source",
|
|
59
|
+
extensions: ["ts", "tsx"],
|
|
60
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const EXCLUDE = /(__tests__|\.test\.(ts|tsx)$|\.integration\.(ts|tsx)$|\.d\.(ts|tsx)$)/;
|
|
64
|
+
|
|
65
|
+
export type Category =
|
|
66
|
+
| "legit-const"
|
|
67
|
+
| "legit-brand"
|
|
68
|
+
| "legit-bridge"
|
|
69
|
+
| "legit-boundary"
|
|
70
|
+
| "suspect-parse"
|
|
71
|
+
| "suspect-narrow"
|
|
72
|
+
| "suspect-general";
|
|
73
|
+
|
|
74
|
+
interface Site {
|
|
75
|
+
file: string;
|
|
76
|
+
line: number;
|
|
77
|
+
category: Category;
|
|
78
|
+
source: string; // source expression (left of `as`)
|
|
79
|
+
target: string; // target type (right of `as`)
|
|
80
|
+
full: string;
|
|
81
|
+
/** boundary-reason wenn der Cast einen `@cast-boundary <reason>`-Marker
|
|
82
|
+
* hat. Nur relevant wenn category === "legit-boundary". */
|
|
83
|
+
boundaryReason?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isStringLiteralLike(node: Node): boolean {
|
|
87
|
+
const k = node.getKind();
|
|
88
|
+
return (
|
|
89
|
+
k === SyntaxKind.StringLiteral ||
|
|
90
|
+
k === SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
91
|
+
k === SyntaxKind.TemplateExpression ||
|
|
92
|
+
k === SyntaxKind.NumericLiteral ||
|
|
93
|
+
k === SyntaxKind.TrueKeyword ||
|
|
94
|
+
k === SyntaxKind.FalseKeyword
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isConstAssertion(cast: AsExpression): boolean {
|
|
99
|
+
const t = cast.getTypeNode();
|
|
100
|
+
return !!t && t.getText() === "const";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Matches `x as unknown as Y` — the inner cast is to `unknown`, the outer
|
|
104
|
+
// to something else. ts-morph exposes this as nested AsExpressions, with
|
|
105
|
+
// optional ParenthesizedExpression in between (depending on parenthesisation):
|
|
106
|
+
// `x as unknown as Y` → AsExpr(target=Y) → AsExpr(target=unknown) → x
|
|
107
|
+
// `(x as unknown) as Y` → AsExpr(target=Y) → ParenExpr → AsExpr(target=unknown) → x
|
|
108
|
+
// Wir gehen durch Parens transparent durch.
|
|
109
|
+
function unwrapParens(node: Node | undefined): Node | undefined {
|
|
110
|
+
while (node?.getKind() === SyntaxKind.ParenthesizedExpression) {
|
|
111
|
+
node = node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression();
|
|
112
|
+
}
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function climbParens(node: Node | undefined): Node | undefined {
|
|
117
|
+
while (node?.getKind() === SyntaxKind.ParenthesizedExpression) {
|
|
118
|
+
node = node.getParent();
|
|
119
|
+
}
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isBridgeInner(cast: AsExpression): boolean {
|
|
124
|
+
const t = cast.getTypeNode()?.getText();
|
|
125
|
+
if (t !== "unknown") return false;
|
|
126
|
+
const parent = climbParens(cast.getParent());
|
|
127
|
+
return parent?.getKind() === SyntaxKind.AsExpression;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isBridgeOuter(cast: AsExpression): boolean {
|
|
131
|
+
const expr = unwrapParens(cast.getExpression());
|
|
132
|
+
if (expr?.getKind() !== SyntaxKind.AsExpression) return false;
|
|
133
|
+
const inner = expr.asKindOrThrow(SyntaxKind.AsExpression);
|
|
134
|
+
return inner.getTypeNode()?.getText() === "unknown";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Is the cast target a Branded-style type? PascalCase identifier ending
|
|
138
|
+
// with "Id" / "Key" / "Token" / "Hash" etc.
|
|
139
|
+
const BRANDED_TARGET_RE = /^[A-Z]\w*(Id|Key|Token|Hash|Name|Ref|Code)$/;
|
|
140
|
+
|
|
141
|
+
// Heuristic: cast to a branded type from one of:
|
|
142
|
+
// • String/numeric literal: `"abc" as TenantId`
|
|
143
|
+
// • PropertyAccess with name-match: `parsed.tenantId as TenantId`,
|
|
144
|
+
// `row.userId as UserId`
|
|
145
|
+
//
|
|
146
|
+
// Property-access requires that the property name (camelCase) matches the
|
|
147
|
+
// target type name (PascalCase) — that's the convention for lifting a
|
|
148
|
+
// validated string/uuid into a branded type. Element-access notation
|
|
149
|
+
// (`payload["tenantId"] as TenantId`) is NOT covered: that pattern means
|
|
150
|
+
// the source is a generic Record/JSON without a typed shape, which is the
|
|
151
|
+
// anti-pattern we want to flag (zod-validate first, then brand off the
|
|
152
|
+
// validated property).
|
|
153
|
+
export function looksLikeBrandConstruction(cast: AsExpression): boolean {
|
|
154
|
+
const target = cast.getTypeNode()?.getText() ?? "";
|
|
155
|
+
if (!BRANDED_TARGET_RE.test(target)) return false;
|
|
156
|
+
|
|
157
|
+
const expr = cast.getExpression();
|
|
158
|
+
if (isStringLiteralLike(expr)) return true;
|
|
159
|
+
|
|
160
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
161
|
+
const propName = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
|
|
162
|
+
// Match property "tenantId" → target "TenantId" (camelCase ↔ PascalCase).
|
|
163
|
+
return propName.length > 0 && propName[0]?.toUpperCase() + propName.slice(1) === target;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Is the cast directly applied to a parse-Call result? Whitelist konkreter
|
|
170
|
+
// Parse-Functions plus Zod's typische `*Schema.parse()` / `*Schema.safeParse()`
|
|
171
|
+
// — `*.parse()` blanket-match wäre zu liberal (jeder eigene helper der
|
|
172
|
+
// `.parse` heißt würde matchen, z.B. `myArray.parse()`).
|
|
173
|
+
const PARSE_CALL_RE =
|
|
174
|
+
/^(?:JSON\.parse|parseJsonSafe|parseJsonOrThrow|\w*[Ss]chema\.(?:parse|safeParse))$/;
|
|
175
|
+
|
|
176
|
+
export function isParseCast(cast: AsExpression): boolean {
|
|
177
|
+
const expr = cast.getExpression();
|
|
178
|
+
if (expr.getKind() !== SyntaxKind.CallExpression) return false;
|
|
179
|
+
const call = expr.asKindOrThrow(SyntaxKind.CallExpression);
|
|
180
|
+
const fn = call.getExpression().getText();
|
|
181
|
+
return PARSE_CALL_RE.test(fn);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Simple-identifier source → likely just "I have a variable, narrow its type".
|
|
185
|
+
// These are the prime candidates for Discriminated Unions or TypeGuards.
|
|
186
|
+
export function isNarrowingCast(cast: AsExpression): boolean {
|
|
187
|
+
return cast.getExpression().getKind() === SyntaxKind.Identifier;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Marker-Kommentar `// @cast-boundary <reason>` der den Cast als
|
|
191
|
+
// bewusste System-Grenze markiert. Statt String-Line-Matching nutzen
|
|
192
|
+
// wir den enclosing-Statement-Range mit Leading- und Trailing-Trivia
|
|
193
|
+
// — deckt leading-Block-Comments, trailing-Line-Comments, inline-
|
|
194
|
+
// Comments in multi-line Casts und mehrere Casts im selben Statement.
|
|
195
|
+
const BOUNDARY_MARKER_RE = /\/[/*]\s*@cast-boundary(?:\s+([\w-]+))?/;
|
|
196
|
+
|
|
197
|
+
// Whitelist anerkannter Reasons. Neue Reason erfordert Eintrag hier —
|
|
198
|
+
// verhindert Drift wie "engine-payload" / "engine_payload" /
|
|
199
|
+
// "engine payload" parallel im Repo. Audit zeigt Warning bei unbekannten
|
|
200
|
+
// Reasons; bei Bedarf erweitern + ggf. Konsumenten umbenennen.
|
|
201
|
+
export const KNOWN_BOUNDARY_REASONS = [
|
|
202
|
+
// Pipeline / Engine
|
|
203
|
+
"engine-payload", // dispatch-Result, event.payload, Hook-Context drilling
|
|
204
|
+
"engine-bridge", // public-API typed fn → erased internal storage (defineFeature builder)
|
|
205
|
+
"error-details", // DispatcherError.details / FieldIssue inspection
|
|
206
|
+
"zod-issue", // ZodIssue-internal shape (path, code, params)
|
|
207
|
+
// Drizzle / DB
|
|
208
|
+
"db-row", // raw drizzle.execute<T>() row access
|
|
209
|
+
"db-operator", // drizzle eq/ne/lt/gt/inArray value-arg (Column-Type vs unknown)
|
|
210
|
+
"db-runner", // ctx.db.runInTx-callback OR TenantDb.raw → DbConnection (Connection|Tx beide drizzle-API-konform)
|
|
211
|
+
"drizzle-bridge", // ProjectionTable + PgTable bridge for getTableName / inspection helpers
|
|
212
|
+
"dynamic-key", // PgTable[k] dynamic-key access (TS-Limitation)
|
|
213
|
+
"user-row", // user-table row → typed shape (e.g. roles JSON-string → string[])
|
|
214
|
+
// Form / Generic
|
|
215
|
+
"form-values", // FormValues<T> dynamic-key indexing
|
|
216
|
+
"generic-record", // generic Record<K,V>-Comparison helpers
|
|
217
|
+
// Rendering
|
|
218
|
+
"render-helper", // Renderer internal field/value resolution
|
|
219
|
+
// Walker / Inspector
|
|
220
|
+
"recursive-walk", // leak-guard, recursive value scanner
|
|
221
|
+
"schema-walk", // feature-AST / schema-shape inspection
|
|
222
|
+
// Custom-Field
|
|
223
|
+
"serialized-field", // dehydrated r.field.X() output → known shape
|
|
224
|
+
// DB
|
|
225
|
+
"tenant-db-row", // tenant-db row access pattern
|
|
226
|
+
// Pattern-Storage
|
|
227
|
+
"projection-table", // implicit entity-projections set `table`
|
|
228
|
+
// UI / Web
|
|
229
|
+
"visual-tree-args", // TargetRef.args is erased to generic args shape
|
|
230
|
+
] as const;
|
|
231
|
+
export type BoundaryReason = (typeof KNOWN_BOUNDARY_REASONS)[number];
|
|
232
|
+
|
|
233
|
+
export function isKnownBoundaryReason(reason: string): reason is BoundaryReason {
|
|
234
|
+
return (KNOWN_BOUNDARY_REASONS as readonly string[]).includes(reason);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function hasBoundaryMarker(cast: AsExpression): boolean {
|
|
238
|
+
return extractBoundaryReason(cast) !== null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Extracts the boundary reason or null if no marker. Returns the first
|
|
242
|
+
// whitespace-separated token after `@cast-boundary` (z.B. "engine-
|
|
243
|
+
// payload"). Empty string when marker has no reason.
|
|
244
|
+
//
|
|
245
|
+
// Scope der Marker-Suche (Reihenfolge: most-specific zuerst):
|
|
246
|
+
// 1. Leading-Comments die laut TS-Compiler dem Statement gehören
|
|
247
|
+
// (`getLeadingCommentRanges` — kein Drift in vorhergehende Zeilen)
|
|
248
|
+
// 2. Cast-Range selbst (multi-line casts mit inline-comment)
|
|
249
|
+
// 3. Same-line trailing-comment am Cast-Ende (nicht weiter — würde
|
|
250
|
+
// den nächsten Statement-Comment fälschlich claimen)
|
|
251
|
+
export function extractBoundaryReason(cast: AsExpression): string | null {
|
|
252
|
+
const sf = cast.getSourceFile();
|
|
253
|
+
const fullText = sf.getFullText();
|
|
254
|
+
|
|
255
|
+
// (1) Leading comments des enclosing statement
|
|
256
|
+
const stmt =
|
|
257
|
+
cast.getFirstAncestorByKind(SyntaxKind.VariableStatement) ??
|
|
258
|
+
cast.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ??
|
|
259
|
+
cast.getFirstAncestorByKind(SyntaxKind.ReturnStatement) ??
|
|
260
|
+
cast.getFirstAncestorByKind(SyntaxKind.PropertyAssignment);
|
|
261
|
+
const leadingRanges = stmt?.getLeadingCommentRanges() ?? [];
|
|
262
|
+
for (const r of leadingRanges) {
|
|
263
|
+
const m = BOUNDARY_MARKER_RE.exec(r.getText());
|
|
264
|
+
if (m) return m[1] ?? "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// (2) Cast-Range selbst (multi-line casts, inline comments innerhalb)
|
|
268
|
+
const castRange = fullText.slice(cast.getStart(), cast.getEnd());
|
|
269
|
+
const inlineMatch = BOUNDARY_MARKER_RE.exec(castRange);
|
|
270
|
+
if (inlineMatch) return inlineMatch[1] ?? "";
|
|
271
|
+
|
|
272
|
+
// (3) Same-line trailing comment am Cast-Ende. Wir gehen vom Cast-End
|
|
273
|
+
// zum nächsten EOL — das umfasst nur comments auf der Cast-Zeile, nicht
|
|
274
|
+
// Folge-Statement-Comments.
|
|
275
|
+
const castEnd = cast.getEnd();
|
|
276
|
+
const eolPos = fullText.indexOf("\n", castEnd);
|
|
277
|
+
const trailingRange = fullText.slice(castEnd, eolPos === -1 ? fullText.length : eolPos);
|
|
278
|
+
const trailingMatch = BOUNDARY_MARKER_RE.exec(trailingRange);
|
|
279
|
+
if (trailingMatch) return trailingMatch[1] ?? "";
|
|
280
|
+
|
|
281
|
+
// (4) Same-line trailing comment am Statement-Ende (für Casts die nicht
|
|
282
|
+
// selbst am Statement-Ende stehen — z.B. innerhalb einer Function-Call
|
|
283
|
+
// Argument-Liste). Suche von Statement-End bis EOL.
|
|
284
|
+
if (stmt) {
|
|
285
|
+
const stmtEnd = stmt.getEnd();
|
|
286
|
+
const stmtEol = fullText.indexOf("\n", stmtEnd);
|
|
287
|
+
const stmtTrailingRange = fullText.slice(stmtEnd, stmtEol === -1 ? fullText.length : stmtEol);
|
|
288
|
+
const stmtTrailingMatch = BOUNDARY_MARKER_RE.exec(stmtTrailingRange);
|
|
289
|
+
if (stmtTrailingMatch) return stmtTrailingMatch[1] ?? "";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Type-Names die per Definition typing-loss-marker sind — siehe
|
|
296
|
+
// `packages/framework/src/db/connection.ts` (DbRow = Record<string, unknown>
|
|
297
|
+
// als bewusster Marker an der Drizzle-Boundary). Cast zu solchen Types
|
|
298
|
+
// IST der Marker — separater `@cast-boundary db-row`-Kommentar wäre
|
|
299
|
+
// redundant. Liste klein halten: nur Types die im Comment ausdrücklich
|
|
300
|
+
// als typing-loss-marker dokumentiert sind.
|
|
301
|
+
const TYPING_LOSS_MARKER_TYPES = new Set(["DbRow", "DbRow | undefined"]);
|
|
302
|
+
|
|
303
|
+
export function isTypingLossMarkerCast(cast: AsExpression): boolean {
|
|
304
|
+
return TYPING_LOSS_MARKER_TYPES.has(cast.getTypeNode()?.getText() ?? "");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// File-Default-Reasons: Verzeichnisse die per Konvention nur eine
|
|
308
|
+
// Sorte boundary-cast enthalten. Statt jeden Cast einzeln mit
|
|
309
|
+
// `@cast-boundary <reason>` zu markieren, gibt der Pfad-Match den
|
|
310
|
+
// Reason vor. Per-Cast-Marker übersteuert diesen Default trotzdem.
|
|
311
|
+
// Konvention beibehalten: nur Pfade die WIRKLICH einheitlich sind
|
|
312
|
+
// (nicht "fast einheitlich" — Drift-Gefahr).
|
|
313
|
+
const FILE_DEFAULT_REASONS: ReadonlyArray<{
|
|
314
|
+
pattern: RegExp;
|
|
315
|
+
reason: BoundaryReason;
|
|
316
|
+
}> = [
|
|
317
|
+
{
|
|
318
|
+
// feature-AST extractors: alle Casts gehen vom erased ts-morph-Parse-
|
|
319
|
+
// Result zu typed feature-Definitionen (EntityDefinition, RelationDef,
|
|
320
|
+
// NavDef, ConfigKeys, …). Per Konstruktion ein schema-walk.
|
|
321
|
+
pattern: /\/engine\/feature-ast\//,
|
|
322
|
+
reason: "schema-walk",
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
export function getFileDefaultReason(filePath: string): BoundaryReason | null {
|
|
327
|
+
for (const entry of FILE_DEFAULT_REASONS) {
|
|
328
|
+
if (entry.pattern.test(filePath)) return entry.reason;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function categorize(cast: AsExpression): Category {
|
|
334
|
+
if (isConstAssertion(cast)) return "legit-const";
|
|
335
|
+
if (isBridgeInner(cast) || isBridgeOuter(cast)) return "legit-bridge";
|
|
336
|
+
if (looksLikeBrandConstruction(cast)) return "legit-brand";
|
|
337
|
+
if (hasBoundaryMarker(cast)) return "legit-boundary";
|
|
338
|
+
if (isTypingLossMarkerCast(cast)) return "legit-boundary";
|
|
339
|
+
if (getFileDefaultReason(cast.getSourceFile().getFilePath()) !== null) return "legit-boundary";
|
|
340
|
+
if (isParseCast(cast)) return "suspect-parse";
|
|
341
|
+
if (isNarrowingCast(cast)) return "suspect-narrow";
|
|
342
|
+
return "suspect-general";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function collect(sf: SourceFile): Site[] {
|
|
346
|
+
const file = path.relative(ROOT, sf.getFilePath());
|
|
347
|
+
const sites: Site[] = [];
|
|
348
|
+
for (const cast of sf.getDescendantsOfKind(SyntaxKind.AsExpression)) {
|
|
349
|
+
// Skip the outer half of a bridge — it's double-counted otherwise. We
|
|
350
|
+
// report only the inner (the `as unknown`) so each bridge shows once.
|
|
351
|
+
if (isBridgeOuter(cast)) continue;
|
|
352
|
+
const category = categorize(cast);
|
|
353
|
+
// TypingLossMarker-Casts (z.B. `as DbRow`) und FileDefault-Casts
|
|
354
|
+
// (z.B. feature-ast/extractors.ts → "schema-walk") sind per Type-
|
|
355
|
+
// Definition / Konvention boundary — synthetic reason damit der
|
|
356
|
+
// unknown-reason-Check nicht fault wirft.
|
|
357
|
+
const reason =
|
|
358
|
+
category === "legit-boundary"
|
|
359
|
+
? (extractBoundaryReason(cast) ??
|
|
360
|
+
(isTypingLossMarkerCast(cast) ? "db-row" : null) ??
|
|
361
|
+
getFileDefaultReason(cast.getSourceFile().getFilePath()))
|
|
362
|
+
: null;
|
|
363
|
+
sites.push({
|
|
364
|
+
file,
|
|
365
|
+
line: cast.getStartLineNumber(),
|
|
366
|
+
category,
|
|
367
|
+
source: cast.getExpression().getText().slice(0, 60),
|
|
368
|
+
target: cast.getTypeNode()?.getText().slice(0, 60) ?? "",
|
|
369
|
+
full: cast.getText().slice(0, 100),
|
|
370
|
+
...(reason ? { boundaryReason: reason } : {}),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return sites;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function scanCasts(files: readonly SourceFile[]): { all: Site[]; scanned: number } {
|
|
377
|
+
const all: Site[] = [];
|
|
378
|
+
let scanned = 0;
|
|
379
|
+
for (const sf of files) {
|
|
380
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
381
|
+
scanned++;
|
|
382
|
+
all.push(...collect(sf));
|
|
383
|
+
}
|
|
384
|
+
return { all, scanned };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const CATS: readonly Category[] = [
|
|
388
|
+
"legit-const",
|
|
389
|
+
"legit-brand",
|
|
390
|
+
"legit-bridge",
|
|
391
|
+
"legit-boundary",
|
|
392
|
+
"suspect-parse",
|
|
393
|
+
"suspect-narrow",
|
|
394
|
+
"suspect-general",
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
|
|
398
|
+
const byCat = new Map<Category, Site[]>();
|
|
399
|
+
for (const s of all) {
|
|
400
|
+
const b = byCat.get(s.category) ?? [];
|
|
401
|
+
b.push(s);
|
|
402
|
+
byCat.set(s.category, b);
|
|
403
|
+
}
|
|
404
|
+
return byCat;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function reportCasts(all: readonly Site[], scanned: number): void {
|
|
408
|
+
const byCat = groupByCategory(all);
|
|
409
|
+
console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
|
|
410
|
+
for (const c of CATS) {
|
|
411
|
+
const count = byCat.get(c)?.length ?? 0;
|
|
412
|
+
console.log(` ${c.padEnd(18)} ${count}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const suspects = CATS.filter((c) => c.startsWith("suspect-"));
|
|
416
|
+
|
|
417
|
+
// Aggregate suspect casts by target type — reveals bulk-refactor patterns
|
|
418
|
+
// (e.g. "20x `as Record<string, unknown>`" → one DB-row helper fixes all).
|
|
419
|
+
console.log("\n Suspect-cast top targets (>=3):");
|
|
420
|
+
const byTarget = new Map<string, Site[]>();
|
|
421
|
+
for (const s of all) {
|
|
422
|
+
if (!s.category.startsWith("suspect-")) continue;
|
|
423
|
+
const bucket = byTarget.get(s.target) ?? [];
|
|
424
|
+
bucket.push(s);
|
|
425
|
+
byTarget.set(s.target, bucket);
|
|
426
|
+
}
|
|
427
|
+
const topTargets = [...byTarget.entries()]
|
|
428
|
+
.filter(([, v]) => v.length >= 3)
|
|
429
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
430
|
+
for (const [target, sites] of topTargets) {
|
|
431
|
+
console.log(` ${sites.length}x as ${target}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const c of suspects) {
|
|
435
|
+
const sites = byCat.get(c);
|
|
436
|
+
if (!sites || sites.length === 0) continue;
|
|
437
|
+
if (c === "suspect-general" && sites.length > 30) {
|
|
438
|
+
console.log(`\n ${c} (${sites.length}, showing top 30 by file):`);
|
|
439
|
+
const sorted = [...sites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
440
|
+
for (const s of sorted.slice(0, 30)) {
|
|
441
|
+
const snippet = s.full.length > 90 ? `${s.full.slice(0, 87)}...` : s.full;
|
|
442
|
+
console.log(` ${s.file}:${s.line} ${snippet}`);
|
|
443
|
+
}
|
|
444
|
+
console.log(` ... (${sites.length - 30} more)`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
console.log(`\n ${c} (${sites.length}):`);
|
|
448
|
+
for (const s of sites) {
|
|
449
|
+
const snippet = s.full.length > 90 ? `${s.full.slice(0, 87)}...` : s.full;
|
|
450
|
+
console.log(` ${s.file}:${s.line} ${snippet}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
console.log(
|
|
455
|
+
"\n Rule: every suspect cast is a candidate for a TypeGuard, Discriminated Union, or better typing at the source.",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Reason-Validation: jeder legit-boundary-Cast sollte einen bekannten Reason
|
|
460
|
+
// haben. Unbekannte Reasons werden gemeldet (Reason-Drift wie „engine-
|
|
461
|
+
// payload" vs „enginePayload" zerstört sonst langfristig die Audit-
|
|
462
|
+
// Aussagekraft) — Whitelist-Erweiterung: in KNOWN_BOUNDARY_REASONS oben
|
|
463
|
+
// eintragen. Warnung, kein Fail (siehe Modul-Header).
|
|
464
|
+
function reportUnknownReasons(all: readonly Site[]): void {
|
|
465
|
+
const unknownReasons: Array<{ file: string; line: number; reason: string }> = [];
|
|
466
|
+
for (const s of all) {
|
|
467
|
+
if (s.category !== "legit-boundary") continue;
|
|
468
|
+
const r = s.boundaryReason ?? "";
|
|
469
|
+
if (r === "" || !isKnownBoundaryReason(r)) {
|
|
470
|
+
unknownReasons.push({ file: s.file, line: s.line, reason: r || "<missing>" });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (unknownReasons.length === 0) return;
|
|
474
|
+
console.log(`\n UNKNOWN @cast-boundary REASONS (${unknownReasons.length}):`);
|
|
475
|
+
for (const u of unknownReasons) {
|
|
476
|
+
console.log(` ${u.file}:${u.line} reason=${u.reason}`);
|
|
477
|
+
}
|
|
478
|
+
console.log(`\n Known reasons: ${KNOWN_BOUNDARY_REASONS.join(", ")}`);
|
|
479
|
+
console.log(" New reason? Add an entry in check-as-casts.ts → KNOWN_BOUNDARY_REASONS.");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const BASELINE_FORMAT_VERSION = 2;
|
|
483
|
+
|
|
484
|
+
type Baseline = {
|
|
485
|
+
format: number;
|
|
486
|
+
generated: string;
|
|
487
|
+
totalSuspect: number;
|
|
488
|
+
perFile: Record<string, Record<string, number>>;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
function suspectByFileAndTarget(all: readonly Site[]): Record<string, Record<string, number>> {
|
|
492
|
+
const result: Record<string, Record<string, number>> = {};
|
|
493
|
+
for (const s of all) {
|
|
494
|
+
if (!s.category.startsWith("suspect-")) continue;
|
|
495
|
+
let fileBuc = result[s.file];
|
|
496
|
+
if (!fileBuc) {
|
|
497
|
+
fileBuc = {};
|
|
498
|
+
result[s.file] = fileBuc;
|
|
499
|
+
}
|
|
500
|
+
fileBuc[s.target] = (fileBuc[s.target] ?? 0) + 1;
|
|
501
|
+
}
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The baseline lives in this repo and may only gate its own files. scanned
|
|
506
|
+
// files also include siblings — their hits show up here as "../<repo>/..."
|
|
507
|
+
// relative to ROOT. Unfiltered, --write-baseline would freeze private
|
|
508
|
+
// sibling paths into the (public) framework history and falsely fail local
|
|
509
|
+
// sibling refactors, invisibly to CI (no sibling checked out there).
|
|
510
|
+
function repoLocal(
|
|
511
|
+
byFileAndTarget: Record<string, Record<string, number>>,
|
|
512
|
+
): Record<string, Record<string, number>> {
|
|
513
|
+
return Object.fromEntries(
|
|
514
|
+
Object.entries(byFileAndTarget).filter(([file]) => !file.startsWith("../")),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function totalOf(byFileAndTarget: Record<string, Record<string, number>>): number {
|
|
519
|
+
return Object.values(byFileAndTarget)
|
|
520
|
+
.flatMap((b) => Object.values(b))
|
|
521
|
+
.reduce((a, b) => a + b, 0);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const baselinePath = path.join(ROOT, ".kumiko-cast-baseline.json");
|
|
525
|
+
|
|
526
|
+
function writeBaseline(all: readonly Site[]): void {
|
|
527
|
+
const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
|
|
528
|
+
const payload: Baseline = {
|
|
529
|
+
format: BASELINE_FORMAT_VERSION,
|
|
530
|
+
generated: new Date().toISOString().slice(0, 10),
|
|
531
|
+
totalSuspect: totalOf(repoLocalSuspects),
|
|
532
|
+
perFile: Object.fromEntries(
|
|
533
|
+
Object.entries(repoLocalSuspects)
|
|
534
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
535
|
+
.map(([file, targets]) => [
|
|
536
|
+
file,
|
|
537
|
+
Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b))),
|
|
538
|
+
]),
|
|
539
|
+
),
|
|
540
|
+
};
|
|
541
|
+
writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
542
|
+
console.log(`\n Baseline written: ${baselinePath}`);
|
|
543
|
+
console.log(` Total suspect: ${payload.totalSuspect} (repo-local)`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Per File:target: aktueller Count gegen baseline. Mehr → Regression.
|
|
547
|
+
// Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
|
|
548
|
+
// obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
|
|
549
|
+
function reportBaseline(all: readonly Site[]): void {
|
|
550
|
+
const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
|
|
551
|
+
const repoLocalTotal = totalOf(repoLocalSuspects);
|
|
552
|
+
|
|
553
|
+
if (!existsSync(baselinePath)) {
|
|
554
|
+
console.log(
|
|
555
|
+
"\n No baseline found. Freeze one with `--write-baseline` first — warning, no fail until then.",
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const rawBaseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as Partial<Baseline>;
|
|
561
|
+
if (rawBaseline.format !== BASELINE_FORMAT_VERSION) {
|
|
562
|
+
console.log(
|
|
563
|
+
`\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${rawBaseline.format ?? "<missing>"}.`,
|
|
564
|
+
);
|
|
565
|
+
console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const baseline = rawBaseline as Baseline;
|
|
569
|
+
|
|
570
|
+
type Regression = { file: string; target: string; baseline: number; current: number };
|
|
571
|
+
const regressions: Regression[] = [];
|
|
572
|
+
let reduced = 0;
|
|
573
|
+
const allFiles = new Set([...Object.keys(repoLocalSuspects), ...Object.keys(baseline.perFile)]);
|
|
574
|
+
for (const file of allFiles) {
|
|
575
|
+
const baselineTargets = baseline.perFile[file] ?? {};
|
|
576
|
+
const currentTargets = repoLocalSuspects[file] ?? {};
|
|
577
|
+
const allTargets = new Set([...Object.keys(baselineTargets), ...Object.keys(currentTargets)]);
|
|
578
|
+
for (const target of allTargets) {
|
|
579
|
+
const expected = baselineTargets[target] ?? 0;
|
|
580
|
+
const current = currentTargets[target] ?? 0;
|
|
581
|
+
if (current > expected) {
|
|
582
|
+
regressions.push({ file, target, baseline: expected, current });
|
|
583
|
+
} else if (current < expected) {
|
|
584
|
+
reduced += expected - current;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (regressions.length > 0) {
|
|
590
|
+
console.log(
|
|
591
|
+
`\n REGRESSION: ${regressions.length} (file, target) pair(s) have more suspect casts than the baseline:`,
|
|
592
|
+
);
|
|
593
|
+
for (const r of regressions) {
|
|
594
|
+
console.log(
|
|
595
|
+
` ${r.file}: as ${r.target} baseline=${r.baseline} current=${r.current} (+${r.current - r.baseline})`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
console.log(
|
|
599
|
+
"\n New casts need a justification. Options:\n" +
|
|
600
|
+
" 1. Avoid the cast (TypeGuard, Discriminated Union, better typing at the source)\n" +
|
|
601
|
+
" 2. If a legit system boundary: add a `// @cast-boundary <reason>` marker\n" +
|
|
602
|
+
" 3. If a cleanup reduction in one file offsets an increase in another: " +
|
|
603
|
+
"run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
|
|
604
|
+
);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
|
|
609
|
+
if (reduced > 0) {
|
|
610
|
+
console.log(
|
|
611
|
+
` ✓ ${reduced} suspect cast(s) reduced since baseline. Run \`--write-baseline\` if appropriate.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
617
|
+
const { all, scanned } = scanCasts(files);
|
|
618
|
+
reportCasts(all, scanned);
|
|
619
|
+
reportUnknownReasons(all);
|
|
620
|
+
if (compareBaseline) reportBaseline(all);
|
|
621
|
+
else console.log("\n Baseline comparison skipped (--no-baseline).");
|
|
622
|
+
// WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
|
|
623
|
+
// unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken.
|
|
624
|
+
return { violations: [] };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export const guard: AstGuard = {
|
|
628
|
+
name: "As-Casts Audit",
|
|
629
|
+
scan: SCAN,
|
|
630
|
+
run: (files) => analyseCasts(files, true),
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
if (import.meta.main) {
|
|
634
|
+
const args = process.argv.slice(2);
|
|
635
|
+
if (args.includes("--write-baseline")) {
|
|
636
|
+
const project = buildSharedProject([guard]);
|
|
637
|
+
const { all, scanned } = scanCasts(filesForGuard(project, guard));
|
|
638
|
+
reportCasts(all, scanned);
|
|
639
|
+
writeBaseline(all);
|
|
640
|
+
process.exit(0);
|
|
641
|
+
}
|
|
642
|
+
if (args.includes("--no-baseline")) {
|
|
643
|
+
const project = buildSharedProject([guard]);
|
|
644
|
+
analyseCasts(filesForGuard(project, guard), false);
|
|
645
|
+
process.exit(0);
|
|
646
|
+
}
|
|
647
|
+
runStandalone(guard);
|
|
648
|
+
}
|