@cosmicdrift/kumiko-guards 0.356.0 → 0.358.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
@@ -17,7 +17,12 @@
17
17
  */
18
18
 
19
19
  import * as path from "node:path";
20
- import { type SourceFile, SyntaxKind } from "ts-morph";
20
+ import {
21
+ type CallExpression,
22
+ type ObjectLiteralExpression,
23
+ type SourceFile,
24
+ SyntaxKind,
25
+ } from "ts-morph";
21
26
  import { findRepoRootFor } from "./_lib/baseline-compare";
22
27
  import {
23
28
  ALL_REPO_KINDS,
@@ -49,6 +54,47 @@ const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/
49
54
 
50
55
  type RoleRestrictedHandler = { name: string; file: string; line: number };
51
56
 
57
+ function writeHandlerOptions(call: CallExpression): ObjectLiteralExpression | undefined {
58
+ const callee = call.getExpression().getText();
59
+ if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) return undefined;
60
+ const arg = call.getArguments()[0];
61
+ if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
62
+ return arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
63
+ }
64
+
65
+ function accessObjectOf(obj: ObjectLiteralExpression): ObjectLiteralExpression | undefined {
66
+ const accessProp = obj.getProperty("access");
67
+ if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
68
+ const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
69
+ if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
70
+ return accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
71
+ }
72
+
73
+ function restrictsToRoles(accessObj: ObjectLiteralExpression): boolean {
74
+ if (accessObj.getProperty("openToAll")) return false;
75
+ const rolesProp = accessObj.getProperty("roles");
76
+ if (!rolesProp) return false;
77
+ if (
78
+ rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
79
+ rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
80
+ )
81
+ return false;
82
+ return !OPEN_ROLE_RE.test(rolesProp.getText());
83
+ }
84
+
85
+ function roleRestrictedHandlerOf(
86
+ call: CallExpression,
87
+ filePath: string,
88
+ ): RoleRestrictedHandler | undefined {
89
+ const obj = writeHandlerOptions(call);
90
+ if (!obj) return undefined;
91
+ const name = literalStringOf(obj.getProperty("name"));
92
+ if (!name) return undefined;
93
+ const accessObj = accessObjectOf(obj);
94
+ if (!accessObj || !restrictsToRoles(accessObj)) return undefined;
95
+ return { name, file: filePath, line: call.getStartLineNumber() };
96
+ }
97
+
52
98
  export function findRoleRestrictedWriteHandlers(
53
99
  files: readonly SourceFile[],
54
100
  ): RoleRestrictedHandler[] {
@@ -56,28 +102,8 @@ export function findRoleRestrictedWriteHandlers(
56
102
  for (const sf of files) {
57
103
  if (TEST_FILE_RE.test(sf.getFilePath())) continue;
58
104
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
59
- const callee = call.getExpression().getText();
60
- if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) continue;
61
- const arg = call.getArguments()[0];
62
- if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
63
- const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
64
- const name = literalStringOf(obj.getProperty("name"));
65
- if (!name) continue;
66
- const accessProp = obj.getProperty("access");
67
- if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) continue;
68
- const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
69
- if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
70
- const accessObj = accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
71
- if (accessObj.getProperty("openToAll")) continue;
72
- const rolesProp = accessObj.getProperty("roles");
73
- if (!rolesProp) continue;
74
- if (
75
- rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
76
- rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
77
- )
78
- continue;
79
- if (OPEN_ROLE_RE.test(rolesProp.getText())) continue;
80
- out.push({ name, file: sf.getFilePath(), line: call.getStartLineNumber() });
105
+ const handler = roleRestrictedHandlerOf(call, sf.getFilePath());
106
+ if (handler) out.push(handler);
81
107
  }
82
108
  }
83
109
  return out;
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env bun
2
- // App-Features folgen der bundled-features-Konvention (Referenz: tenant/):
3
- // feature.ts = nur Registrierung, Screens unter web/, Handler unter handlers/.
4
- // Dieser Guard flaggt die drei teuersten Abweichungen:
5
- // 1. web.tsx/web.ts-Monolith bzw. JSX-Screens direkt am Feature-Root
6
- // 2. feature.ts als Logik-Dump (> MAX_FEATURE_TS_LINES Zeilen)
7
- // 3. r.screen({ type: "custom" }) ohne Allowlist-Tag — deklarative
8
- // Screen-Typen (entityList/dashboard/data-table) sind der Default.
2
+ // App features follow the bundled-features convention (reference: tenant/):
3
+ // feature.ts = registration only, screens under web/, handlers under handlers/.
4
+ // This guard flags the three most expensive deviations:
5
+ // 1. web.tsx/web.ts monolith or JSX screens directly at the feature root
6
+ // 2. feature.ts as a logic dump (> MAX_FEATURE_TS_LINES lines)
7
+ // 3. r.screen({ type: "custom" }) without an allowlist tag — declarative
8
+ // screen types (entityList/dashboard/data-table) are the default.
9
9
  //
10
- // ponytail: Handler-Datei-Konvention (*.query.ts/*.write.ts unter handlers/)
11
- // wird noch nicht erzwungen — nachziehen, wenn die Registrierungs-API-Formen
12
- // stabil inventarisiert sind.
10
+ // ponytail: the handler file convention (*.query.ts/*.write.ts under handlers/)
11
+ // is not enforced yet — add it once the registration API shapes are
12
+ // inventoried and stable.
13
13
  //
14
- // Teil von App-Mounting 2.0 (infra#208).
14
+ // Part of App-Mounting 2.0 (infra#208).
15
15
 
16
16
  import * as path from "node:path";
17
17
  import { type SourceFile, SyntaxKind } from "ts-morph";
@@ -29,13 +29,88 @@ const IGNORE_TAG = "kumiko-lint-ignore app-feature-structure";
29
29
 
30
30
  const MAX_FEATURE_TS_LINES = 300;
31
31
 
32
- // src/features/<name>/<file> bzw. packages/bundled-features/src/<name>/<file>
33
- // — genau eine Ebene unter dem Feature-Ordner.
32
+ // src/features/<name>/<file> or packages/bundled-features/src/<name>/<file>
33
+ // — exactly one level below the feature folder.
34
34
  function isFeatureRootFile(filePath: string): boolean {
35
35
  const m = filePath.match(/(src\/features|packages\/bundled-features\/src)\/[^/]+\/[^/]+$/);
36
36
  return m !== null;
37
37
  }
38
38
 
39
+ function isIgnoredFile(sf: SourceFile): boolean {
40
+ return hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG);
41
+ }
42
+
43
+ // 1a. web.ts(x) monolith at the feature root
44
+ function checkWebMonolith(sf: SourceFile, base: string, violations: GuardViolation[]): void {
45
+ const filePath = sf.getFilePath();
46
+ if (isFeatureRootFile(filePath) && (base === "web.tsx" || base === "web.ts")) {
47
+ if (!isIgnoredFile(sf)) {
48
+ violations.push({
49
+ file: filePath,
50
+ line: 1,
51
+ message:
52
+ "web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ // 1b. JSX directly at the feature root (screens belong under web/)
59
+ function checkRootJsx(sf: SourceFile, base: string, violations: GuardViolation[]): void {
60
+ const filePath = sf.getFilePath();
61
+ if (
62
+ isFeatureRootFile(filePath) &&
63
+ filePath.endsWith(".tsx") &&
64
+ base !== "web.tsx" &&
65
+ sf.getDescendantsOfKind(SyntaxKind.JsxElement).length +
66
+ sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length >
67
+ 0 &&
68
+ !isIgnoredFile(sf)
69
+ ) {
70
+ violations.push({
71
+ file: filePath,
72
+ line: 1,
73
+ message: "JSX component at feature root — move it under web/",
74
+ });
75
+ }
76
+ }
77
+
78
+ // 2. feature.ts as a logic dump
79
+ function checkFeatureTsSize(sf: SourceFile, base: string, violations: GuardViolation[]): void {
80
+ const filePath = sf.getFilePath();
81
+ if (base === "feature.ts" && isFeatureRootFile(filePath)) {
82
+ const lines = sf.getEndLineNumber();
83
+ if (lines > MAX_FEATURE_TS_LINES && !isIgnoredFile(sf)) {
84
+ violations.push({
85
+ file: filePath,
86
+ line: 1,
87
+ message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ // 3. type: "custom" without an allowlist tag
94
+ function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void {
95
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
96
+ if (prop.getName() !== "type") continue;
97
+ const init = prop.getInitializer();
98
+ if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
99
+ if (init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText() !== "custom") continue;
100
+ // r.screen context only: the surrounding call target must end in .screen.
101
+ const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
102
+ if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
103
+ // Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
104
+ if (prop.getParent() !== call.getArguments()[0]) continue;
105
+ if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
106
+ violations.push({
107
+ file: sf.getFilePath(),
108
+ line: prop.getStartLineNumber(),
109
+ message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
110
+ });
111
+ }
112
+ }
113
+
39
114
  export const guard: AstGuard = {
40
115
  name: "App-Feature-Structure Guard (App-Repos)",
41
116
  scan: SCAN,
@@ -48,66 +123,10 @@ export const guard: AstGuard = {
48
123
  const filePath = sf.getFilePath();
49
124
  if (EXCLUDE.test(filePath)) continue;
50
125
  const base = path.basename(filePath);
51
-
52
- // 1a. web.ts(x)-Monolith am Feature-Root
53
- if (isFeatureRootFile(filePath) && (base === "web.tsx" || base === "web.ts")) {
54
- if (!hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)) {
55
- violations.push({
56
- file: filePath,
57
- line: 1,
58
- message:
59
- "web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
60
- });
61
- }
62
- }
63
-
64
- // 1b. JSX direkt am Feature-Root (Screens gehören unter web/)
65
- if (
66
- isFeatureRootFile(filePath) &&
67
- filePath.endsWith(".tsx") &&
68
- base !== "web.tsx" &&
69
- sf.getDescendantsOfKind(SyntaxKind.JsxElement).length +
70
- sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length >
71
- 0 &&
72
- !hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)
73
- ) {
74
- violations.push({
75
- file: filePath,
76
- line: 1,
77
- message: "JSX component at feature root — move it under web/",
78
- });
79
- }
80
-
81
- // 2. feature.ts als Logik-Dump
82
- if (base === "feature.ts" && isFeatureRootFile(filePath)) {
83
- const lines = sf.getEndLineNumber();
84
- if (lines > MAX_FEATURE_TS_LINES && !hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)) {
85
- violations.push({
86
- file: filePath,
87
- line: 1,
88
- message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
89
- });
90
- }
91
- }
92
-
93
- // 3. type: "custom" ohne Allowlist-Tag
94
- for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
95
- if (prop.getName() !== "type") continue;
96
- const init = prop.getInitializer();
97
- if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
98
- if (init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText() !== "custom") continue;
99
- // Nur r.screen-Kontext: das umgebende Call-Target muss auf .screen enden.
100
- const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
101
- if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
102
- // Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
103
- if (prop.getParent() !== call.getArguments()[0]) continue;
104
- if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
105
- violations.push({
106
- file: filePath,
107
- line: prop.getStartLineNumber(),
108
- message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
109
- });
110
- }
126
+ checkWebMonolith(sf, base, violations);
127
+ checkRootJsx(sf, base, violations);
128
+ checkFeatureTsSize(sf, base, violations);
129
+ checkCustomScreens(sf, violations);
111
130
  }
112
131
  return { violations };
113
132
  },
@@ -1,30 +1,30 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard R8: verbietet `broker.subscribe(...)` ausserhalb des Frameworks.
3
+ * Guard R8: forbids `broker.subscribe(...)` outside the framework.
4
4
  *
5
- * Feature- und App-Code soll NIE direkt an den Event-Broker subscriben — die
6
- * Registrar-API ist der einzige erlaubte Weg, Events zu konsumieren:
5
+ * Feature and app code must NEVER subscribe to the event broker directly — the
6
+ * registrar API is the only allowed way to consume events:
7
7
  * - `r.onEvent("event-name", handler)`
8
8
  * - `r.job({ trigger: { on: "event-name" }, handler })`
9
9
  *
10
- * Ein direktes `broker.subscribe(...)` umgeht Lifecycle, Idempotency, Dedup
11
- * und Replay des Dispatchers — genau die Garantien, die die Registrar-API
12
- * gibt. Heute gibt es im Kumiko-Code KEINEN solchen Call (der Broker ist
13
- * framework-intern, nicht exportiert) — der Guard ist ein Tripwire, der
14
- * zuschlägt, sobald jemand eine Broker-Abstraktion einführt und Feature-Code
15
- * direkt daran hängt.
10
+ * A direct `broker.subscribe(...)` bypasses lifecycle, idempotency, dedup
11
+ * and replay of the dispatcher — exactly the guarantees the registrar API
12
+ * gives. Today there is NO such call in the Kumiko code (the broker is
13
+ * framework-internal, not exported) — the guard is a tripwire that fires as
14
+ * soon as someone introduces a broker abstraction and hooks feature code
15
+ * directly onto it.
16
16
  *
17
- * Erkennung (Name-Heuristik, keine Typ-Resolution): `X.subscribe(...)` wo der
18
- * terminale Bezeichner von `X` `broker` oder `eventBroker` heisst (case-
19
- * insensitive — fängt `broker`, `eventBroker`, `ctx.broker`, `this.eventBroker`).
20
- * Store-/Observable-`.subscribe` (RxJS, React `controller.subscribe`) heisst
21
- * nicht `broker` → kein Treffer.
17
+ * Detection (name heuristic, no type resolution): `X.subscribe(...)` where the
18
+ * terminal identifier of `X` is `broker` or `eventBroker` (case-
19
+ * insensitive — catches `broker`, `eventBroker`, `ctx.broker`, `this.eventBroker`).
20
+ * Store/observable `.subscribe` (RxJS, React `controller.subscribe`) is not
21
+ * named `broker` → no hit.
22
22
  *
23
- * Ausnahme: `packages/framework/src/pipeline/**` — dort lebt das Broker-
24
- * Plumbing selbst (framework-intern, erlaubt).
23
+ * Exception: `packages/framework/src/pipeline/**` — the broker plumbing
24
+ * itself lives there (framework-internal, allowed).
25
25
  *
26
26
  * Usage: bun guards/guard-broker-subscribe.ts
27
- * Exit 1 bei Fund, 0 wenn sauber.
27
+ * Exit 1 on a finding, 0 when clean.
28
28
  */
29
29
 
30
30
  import * as path from "node:path";
@@ -50,6 +50,32 @@ export type Site = {
50
50
  readonly snippet: string;
51
51
  };
52
52
 
53
+ function germanCommentSite(
54
+ fullText: string,
55
+ file: string,
56
+ start: number,
57
+ text: string,
58
+ ): Site | undefined {
59
+ if (lineHasIgnoreTag(text, IGNORE_TAG) || !isGermanComment(text)) return undefined;
60
+ const line = fullText.slice(0, start).split("\n").length;
61
+ return {
62
+ file,
63
+ line,
64
+ endLine: line + (text.match(/\n/g)?.length ?? 0),
65
+ snippet: text.length > 80 ? `${text.slice(0, 77)}...` : text,
66
+ };
67
+ }
68
+
69
+ function closesTemplateSubstitution(
70
+ templateBraceDepths: readonly number[],
71
+ braceDepth: number,
72
+ ): boolean {
73
+ return (
74
+ templateBraceDepths.length > 0 &&
75
+ braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
76
+ );
77
+ }
78
+
53
79
  // Template substitutions can nest braces (object literals, arrow bodies, other
54
80
  // templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
55
81
  // never resumes template mode, so reScanTemplateToken() must run once brace
@@ -71,16 +97,13 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
71
97
  switch (kind) {
72
98
  case ts.SyntaxKind.SingleLineCommentTrivia:
73
99
  case ts.SyntaxKind.MultiLineCommentTrivia: {
74
- const text = scanner.getTokenText();
75
- if (!lineHasIgnoreTag(text, IGNORE_TAG) && isGermanComment(text)) {
76
- const line = fullText.slice(0, scanner.getTokenStart()).split("\n").length;
77
- sites.push({
78
- file,
79
- line,
80
- endLine: line + (text.match(/\n/g)?.length ?? 0),
81
- snippet: text.length > 80 ? `${text.slice(0, 77)}...` : text,
82
- });
83
- }
100
+ const site = germanCommentSite(
101
+ fullText,
102
+ file,
103
+ scanner.getTokenStart(),
104
+ scanner.getTokenText(),
105
+ );
106
+ if (site) sites.push(site);
84
107
  break;
85
108
  }
86
109
  case ts.SyntaxKind.SlashToken:
@@ -99,10 +122,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
99
122
  braceDepth++;
100
123
  break;
101
124
  case ts.SyntaxKind.CloseBraceToken:
102
- if (
103
- templateBraceDepths.length > 0 &&
104
- braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
105
- ) {
125
+ if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
106
126
  templateBraceDepths.pop();
107
127
  kind = scanner.reScanTemplateToken(false);
108
128
  continue;
@@ -262,47 +282,71 @@ function sumCounts(counts: Readonly<Record<string, number>>): number {
262
282
  return Object.values(counts).reduce((total, count) => total + count, 0);
263
283
  }
264
284
 
265
- function runBaselineMode(all: readonly Site[], argv: readonly string[], root: string): number {
266
- const baselinePath = path.join(root, BASELINE_FILE);
285
+ function isBaselineCandidate(value: unknown): value is Partial<Baseline> {
286
+ return typeof value === "object" && value !== null;
287
+ }
288
+
289
+ function hasPerFileObject(baseline: Partial<Baseline>): baseline is Partial<Baseline> & {
290
+ readonly perFile: Record<string, number>;
291
+ } {
292
+ return typeof baseline.perFile === "object" && baseline.perFile !== null;
293
+ }
294
+
295
+ function countFindingsByFile(all: readonly Site[]): Record<string, number> {
267
296
  const countByFile: Record<string, number> = {};
268
297
  for (const site of all.filter(isLocalFinding)) {
269
298
  countByFile[site.file] = (countByFile[site.file] ?? 0) + 1;
270
299
  }
271
- const currentTotal = sumCounts(countByFile);
300
+ return countByFile;
301
+ }
272
302
 
273
- if (argv.includes("--write-baseline")) {
274
- const payload: Baseline = {
275
- format: BASELINE_FORMAT_VERSION,
276
- generated: new Date().toISOString().slice(0, 10),
277
- total: currentTotal,
278
- perFile: Object.fromEntries(
279
- Object.entries(countByFile).sort(([a], [b]) => a.localeCompare(b)),
280
- ),
281
- };
282
- writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
283
- console.log(` Baseline written: ${baselinePath} (total ${currentTotal})`);
284
- return 0;
285
- }
286
- if (argv.includes("--no-baseline")) {
287
- console.log(" Baseline comparison skipped (--no-baseline).");
288
- return 0;
289
- }
290
- if (!existsSync(baselinePath)) {
291
- console.log(" No baseline found. Freeze one first with `--write-baseline`.");
292
- return 1;
293
- }
303
+ function writeBaselineFile(
304
+ baselinePath: string,
305
+ countByFile: Readonly<Record<string, number>>,
306
+ currentTotal: number,
307
+ ): void {
308
+ const payload: Baseline = {
309
+ format: BASELINE_FORMAT_VERSION,
310
+ generated: Temporal.Now.instant().toString().slice(0, 10),
311
+ total: currentTotal,
312
+ perFile: Object.fromEntries(Object.entries(countByFile).sort(([a], [b]) => a.localeCompare(b))),
313
+ };
314
+ writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
315
+ console.log(` Baseline written: ${baselinePath} (total ${currentTotal})`);
316
+ }
317
+
318
+ type LoadedBaseline = Partial<Baseline> & { readonly perFile: Record<string, number> };
294
319
 
295
- const rawBaseline: Partial<Baseline> = JSON.parse(readFileSync(baselinePath, "utf-8"));
296
- if (rawBaseline.format !== BASELINE_FORMAT_VERSION || rawBaseline.perFile === undefined) {
320
+ // Prints the diagnosis itself and returns undefined when the baseline cannot be used.
321
+ function readBaselineOrReport(baselinePath: string): LoadedBaseline | undefined {
322
+ let parsed: unknown;
323
+ try {
324
+ parsed = JSON.parse(readFileSync(baselinePath, "utf-8"));
325
+ } catch (error) {
326
+ console.log(
327
+ ` Baseline unreadable: ${error instanceof Error ? error.message : String(error)}. Rewrite it once with: kumiko-guards comment-lang --write-baseline`,
328
+ );
329
+ return undefined;
330
+ }
331
+ // JSON `null`, numbers and strings parse fine but carry no baseline: report them as format drift.
332
+ const rawBaseline: Partial<Baseline> = isBaselineCandidate(parsed) ? parsed : {};
333
+ if (rawBaseline.format !== BASELINE_FORMAT_VERSION || !hasPerFileObject(rawBaseline)) {
297
334
  console.log(
298
335
  ` Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, got format=${rawBaseline.format ?? "<missing>"}.`,
299
336
  );
300
337
  console.log(" Rewrite it once with: kumiko-guards comment-lang --write-baseline");
301
- return 1;
338
+ return undefined;
302
339
  }
303
- const baselinePerFile = rawBaseline.perFile;
340
+ return rawBaseline;
341
+ }
304
342
 
305
- const regressions: Array<{ file: string; baseline: number; current: number }> = [];
343
+ type BaselineRegression = { file: string; baseline: number; current: number };
344
+
345
+ function compareCountsToBaseline(
346
+ countByFile: Readonly<Record<string, number>>,
347
+ baselinePerFile: Readonly<Record<string, number>>,
348
+ ): { regressions: BaselineRegression[]; reduced: number } {
349
+ const regressions: BaselineRegression[] = [];
306
350
  let reduced = 0;
307
351
  for (const file of new Set([...Object.keys(countByFile), ...Object.keys(baselinePerFile)])) {
308
352
  const expected = baselinePerFile[file] ?? 0;
@@ -310,24 +354,52 @@ function runBaselineMode(all: readonly Site[], argv: readonly string[], root: st
310
354
  if (current > expected) regressions.push({ file, baseline: expected, current });
311
355
  else if (current < expected) reduced += expected - current;
312
356
  }
357
+ return { regressions, reduced };
358
+ }
313
359
 
314
- if (regressions.length > 0) {
360
+ function printRegressions(all: readonly Site[], regressions: readonly BaselineRegression[]): void {
361
+ console.log(
362
+ `\n REGRESSION: ${regressions.length} file(s) have more German comments than the baseline:`,
363
+ );
364
+ for (const regression of regressions) {
315
365
  console.log(
316
- `\n REGRESSION: ${regressions.length} file(s) have more German comments than the baseline:`,
366
+ ` ${regression.file} baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline})`,
317
367
  );
318
- for (const regression of regressions) {
319
- console.log(
320
- ` ${regression.file} baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline})`,
321
- );
322
- for (const site of all.filter((candidate) => candidate.file === regression.file)) {
323
- console.log(` :${site.line} ${site.snippet}`);
324
- }
368
+ for (const site of all.filter((candidate) => candidate.file === regression.file)) {
369
+ console.log(` :${site.line} ${site.snippet}`);
325
370
  }
326
- console.log(`\n ${REWRITE_HINT}`);
371
+ }
372
+ console.log(`\n ${REWRITE_HINT}`);
373
+ }
374
+
375
+ function runBaselineMode(all: readonly Site[], argv: readonly string[], root: string): number {
376
+ const baselinePath = path.join(root, BASELINE_FILE);
377
+ const countByFile = countFindingsByFile(all);
378
+ const currentTotal = sumCounts(countByFile);
379
+
380
+ if (argv.includes("--write-baseline")) {
381
+ writeBaselineFile(baselinePath, countByFile, currentTotal);
382
+ return 0;
383
+ }
384
+ if (argv.includes("--no-baseline")) {
385
+ console.log(" Baseline comparison skipped (--no-baseline).");
386
+ return 0;
387
+ }
388
+ if (!existsSync(baselinePath)) {
389
+ console.log(" No baseline found. Freeze one first with `--write-baseline`.");
390
+ return 1;
391
+ }
392
+
393
+ const baseline = readBaselineOrReport(baselinePath);
394
+ if (baseline === undefined) return 1;
395
+
396
+ const { regressions, reduced } = compareCountsToBaseline(countByFile, baseline.perFile);
397
+ if (regressions.length > 0) {
398
+ printRegressions(all, regressions);
327
399
  return 1;
328
400
  }
329
401
 
330
- console.log(` ✓ Baseline (${rawBaseline.total}) — current ${currentTotal}`);
402
+ console.log(` ✓ Baseline (${baseline.total}) — current ${currentTotal}`);
331
403
  if (reduced > 0) {
332
404
  console.log(
333
405
  ` ✓ ${reduced} German comment(s) removed since the baseline. Consider \`--write-baseline\`.`,