@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.
- package/package.json +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
package/src/check-as-casts.ts
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `as X`
|
|
4
|
+
* `as X` cast audit with a baseline regression report (WARNING, no fail).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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` (
|
|
10
|
-
* • legit-brand — `"..." as BrandedType` (
|
|
11
|
-
* • legit-bridge — `x as unknown as Y` (
|
|
12
|
-
* • legit-boundary —
|
|
13
|
-
* (
|
|
14
|
-
* • suspect-parse — cast
|
|
15
|
-
* • suspect-narrow — cast
|
|
16
|
-
* • suspect-general —
|
|
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
|
-
*
|
|
18
|
+
* The first four are legitimate. The last three are refactoring candidates.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
27
|
+
* Baseline regression report:
|
|
28
28
|
*
|
|
29
|
-
* `.kumiko-cast-baseline.json`
|
|
30
|
-
* suspect-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 #
|
|
39
|
-
* bun guards/check-as-casts.ts --write-baseline #
|
|
40
|
-
* bun guards/check-as-casts.ts --no-baseline #
|
|
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
|
|
83
|
-
*
|
|
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-
|
|
171
|
-
//
|
|
172
|
-
// — `*.parse()`
|
|
173
|
-
// `.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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// —
|
|
195
|
-
//
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
|
247
|
-
// 1. Leading
|
|
248
|
-
// (`getLeadingCommentRanges` —
|
|
249
|
-
// 2.
|
|
250
|
-
// 3. Same-line trailing
|
|
251
|
-
//
|
|
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
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
291
|
+
// Type names that are typing-loss markers by definition — see
|
|
297
292
|
// `packages/framework/src/db/connection.ts` (DbRow = Record<string, unknown>
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// redundant.
|
|
301
|
-
//
|
|
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-
|
|
309
|
-
//
|
|
310
|
-
// `@cast-boundary <reason
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
// (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
355
|
-
// (
|
|
356
|
-
//
|
|
357
|
-
//
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
438
|
+
console.log(` ... (${sites.length - 30} more)`);
|
|
439
|
+
} else {
|
|
440
|
+
console.log(`\n ${category} (${sites.length}):`);
|
|
456
441
|
for (const s of sites) {
|
|
457
|
-
|
|
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
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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:
|
|
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
|
-
//
|
|
556
|
-
//
|
|
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";
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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
|
|