@cosmicdrift/kumiko-guards 0.1.0 → 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.
Files changed (54) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/git-env.ts +22 -0
  3. package/src/_lib/guard-kit.ts +74 -19
  4. package/src/_lib/qn.ts +21 -0
  5. package/src/_lib/roots.ts +1 -18
  6. package/src/_lib/security-baseline-cli.ts +3 -3
  7. package/src/_lib/security-baseline.ts +8 -8
  8. package/src/changes.json +40 -0
  9. package/src/check-as-casts.ts +648 -0
  10. package/src/check-complexity.ts +292 -0
  11. package/src/check-predicates.ts +218 -0
  12. package/src/check-secret-literals.ts +126 -0
  13. package/src/cli.ts +59 -0
  14. package/src/guard-admin-api.ts +1 -1
  15. package/src/guard-app-feature-structure.ts +114 -0
  16. package/src/guard-broker-subscribe.ts +99 -0
  17. package/src/guard-error-reasons.ts +185 -0
  18. package/src/guard-escape-hatch-declared.ts +26 -19
  19. package/src/guard-fake-tests.ts +1 -1
  20. package/src/guard-feature-integration-tests.ts +184 -0
  21. package/src/guard-html-escape.ts +1 -1
  22. package/src/guard-i18n-keys.ts +440 -0
  23. package/src/guard-i18n-locale-mount.ts +317 -0
  24. package/src/guard-i18n-locale-terminology.ts +117 -0
  25. package/src/guard-i18n-ui-strings.ts +248 -0
  26. package/src/guard-lib-test-coverage.ts +156 -0
  27. package/src/guard-loadall-events.ts +133 -0
  28. package/src/guard-no-custom-primitives.ts +9 -10
  29. package/src/guard-no-date-api.ts +1 -1
  30. package/src/guard-no-direct-fs.ts +1 -1
  31. package/src/guard-no-inline-styles.ts +4 -4
  32. package/src/guard-no-logic-in-views.ts +3 -3
  33. package/src/guard-no-raw-hooks.ts +4 -5
  34. package/src/guard-open-to-all-reason.ts +1 -1
  35. package/src/guard-pii-annotations.ts +267 -0
  36. package/src/guard-pre-es-patterns.ts +1 -1
  37. package/src/guard-primitives-discipline.ts +3 -3
  38. package/src/guard-raw-classname.ts +3 -3
  39. package/src/guard-raw-interactive-elements.ts +3 -3
  40. package/src/guard-raw-sql.ts +2 -2
  41. package/src/guard-renderer-boundaries.ts +1 -1
  42. package/src/guard-restricted-symbols.ts +1 -1
  43. package/src/guard-screen-conventions.ts +161 -0
  44. package/src/guard-silent-skip.ts +1 -1
  45. package/src/guard-table-ddl.ts +159 -0
  46. package/src/guard-tailwind-scan-surface.ts +12 -12
  47. package/src/guard-test-stack-drift.ts +147 -0
  48. package/src/guard-text-field-stance.ts +222 -0
  49. package/src/guard-thin-wrappers.ts +6 -1
  50. package/src/guard-unsafe-json-parse.ts +1 -1
  51. package/src/guard-write-handler-qns.ts +242 -0
  52. package/src/run-guards.ts +36 -3
  53. package/src/run-repo-checks.ts +10 -1
  54. package/src/run-ui-guards.ts +11 -2
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: PII-typische Entity-Feldnamen ohne Annotation, mit Baseline-
4
+ * Regression-Guard wie check-complexity.ts.
5
+ *
6
+ * Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
7
+ * fields with { pii: true }, { userOwned }, { tenantOwned: true },
8
+ * { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
9
+ * successor to the four legacy subject annotations).
10
+ *
11
+ * `.kumiko-pii-annotations-baseline.json` im Repo-Root pinnt pro File die
12
+ * eingefrorene Fund-Anzahl:
13
+ * - aktuell <= baseline pro File: PASS
14
+ * - aktuell > baseline pro File: FAIL (neues unannotiertes PII-Feld)
15
+ * Reduktionen updaten die Baseline NICHT automatisch — nach Annotations-
16
+ * Commits `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Guard
17
+ * warning-only (Bootstrap: einmalig `--write-baseline`).
18
+ *
19
+ * Usage:
20
+ * bun guards/guard-pii-annotations.ts # Vergleich gegen Baseline
21
+ * bun guards/guard-pii-annotations.ts --write-baseline # Baseline neu schreiben
22
+ * bun guards/guard-pii-annotations.ts --no-baseline # Vergleich überspringen
23
+ */
24
+ import * as path from "node:path";
25
+ import {
26
+ type CallExpression,
27
+ type Node,
28
+ type ObjectLiteralExpression,
29
+ type SourceFile,
30
+ SyntaxKind,
31
+ } from "ts-morph";
32
+ import {
33
+ type AstGuard,
34
+ baselineRatchet,
35
+ buildSharedProject,
36
+ filesForGuard,
37
+ type GuardOutcome,
38
+ type GuardViolation,
39
+ isLocalFinding,
40
+ runStandalone,
41
+ type ScanSpec,
42
+ } from "./_lib/guard-kit";
43
+
44
+ const ROOT = process.cwd();
45
+
46
+ const SCAN: ScanSpec = {
47
+ scope: "source",
48
+ extensions: ["ts"],
49
+ frameworkWithin: ["packages/*/src/**"],
50
+ };
51
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
52
+
53
+ // Keep in sync with boot-validator/entity-handler.ts PII_*_NAME_HINTS.
54
+ const PII_DIRECT_NAME_HINTS = new Set([
55
+ "email",
56
+ "phone",
57
+ "phonenumber",
58
+ "mobile",
59
+ "address",
60
+ "street",
61
+ "postalcode",
62
+ "zipcode",
63
+ "zip",
64
+ "city",
65
+ "displayname",
66
+ "firstname",
67
+ "lastname",
68
+ "fullname",
69
+ "birthday",
70
+ "birthdate",
71
+ "dateofbirth",
72
+ "dob",
73
+ "ssn",
74
+ "taxid",
75
+ "vatid",
76
+ "passport",
77
+ "iban",
78
+ "bic",
79
+ ]);
80
+
81
+ const PII_USER_OWNED_NAME_HINTS = new Set([
82
+ "body",
83
+ "text",
84
+ "content",
85
+ "message",
86
+ "comment",
87
+ "description",
88
+ "note",
89
+ "notes",
90
+ ]);
91
+
92
+ function relFile(sf: SourceFile): string {
93
+ return path.relative(ROOT, sf.getFilePath());
94
+ }
95
+
96
+ function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
97
+ const first = call.getArguments()[0];
98
+ return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
99
+ }
100
+
101
+ // Subject annotations answering "is this PII" (kept in sync with the
102
+ // author-facing PersonalAnnotations type, @cosmicdrift/kumiko-types
103
+ // packages/types/src/fields.ts). `personal` (0.210.0) is the successor
104
+ // to the four legacy fields below — every value counts as answered,
105
+ // including `personal: false` (the framework requires a `reason` for
106
+ // it). `find` is NOT a silencer: it resolves to lookupable/searchable/
107
+ // sensitive, which never answered the PII question either. `encrypted`
108
+ // isn't one either — the boot validator checks it only together with
109
+ // `sensitive` (ciphertext-at-rest), never as a substitute.
110
+ function isPiiSilencerAssignment(name: string, init: Node | undefined): boolean {
111
+ if (name === "personal") {
112
+ return (
113
+ init !== undefined &&
114
+ init.getKind() !== SyntaxKind.UndefinedKeyword &&
115
+ !(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
116
+ init.getKind() !== SyntaxKind.NullKeyword
117
+ );
118
+ }
119
+ if (name === "allowPlaintext") return init?.isKind(SyntaxKind.StringLiteral) ?? false;
120
+ if (name === "pii") return init?.getKind() === SyntaxKind.TrueKeyword;
121
+ if (name === "tenantOwned") return init?.getKind() === SyntaxKind.TrueKeyword;
122
+ if (name === "userOwned") return init?.isKind(SyntaxKind.ObjectLiteralExpression) ?? false;
123
+ return false;
124
+ }
125
+
126
+ function objectHasPiiSilencer(obj: ObjectLiteralExpression): boolean {
127
+ for (const prop of obj.getProperties()) {
128
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
129
+ if (isPiiSilencerAssignment(prop.getName(), prop.getInitializer())) return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ function enclosingFieldName(call: CallExpression): string | null {
135
+ let node = call.getParent();
136
+ while (node) {
137
+ if (node.isKind(SyntaxKind.PropertyAssignment)) {
138
+ return node.getName();
139
+ }
140
+ node = node.getParent();
141
+ }
142
+ return null;
143
+ }
144
+
145
+ export interface Finding {
146
+ file: string;
147
+ line: number;
148
+ fieldName: string;
149
+ hint: string;
150
+ }
151
+
152
+ function scanFieldFactories(sf: SourceFile): Finding[] {
153
+ const findings: Finding[] = [];
154
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
155
+ const callee = call.getExpression().getText();
156
+ if (callee !== "createTextField" && callee !== "createLongTextField") continue;
157
+
158
+ const fieldName = enclosingFieldName(call);
159
+ if (!fieldName) continue;
160
+
161
+ const options = fieldFactoryOptions(call);
162
+ if (options && objectHasPiiSilencer(options)) continue;
163
+
164
+ const lower = fieldName.toLowerCase();
165
+ let hint: string | null = null;
166
+ if (PII_DIRECT_NAME_HINTS.has(lower)) {
167
+ hint = `{ personal: ... } or { pii: true } or { allowPlaintext: "is-business-data" }`;
168
+ } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
169
+ hint = `{ personal: { of: "<authorIdField>" }, find: "exact" } or { userOwned: { ownerField: "<authorIdField>" } } or { allowPlaintext: "..." }`;
170
+ }
171
+ if (!hint) continue;
172
+
173
+ const line = call.getStartLineNumber();
174
+ findings.push({ file: relFile(sf), line, fieldName, hint });
175
+ console.warn(
176
+ ` [pii-annotations WARN] ${relFile(sf)}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
177
+ );
178
+ }
179
+ return findings;
180
+ }
181
+
182
+ function scan(files: readonly SourceFile[]): {
183
+ findings: Finding[];
184
+ scanned: number;
185
+ } {
186
+ const findings: Finding[] = [];
187
+ let scanned = 0;
188
+ for (const sf of files) {
189
+ if (EXCLUDE.test(sf.getFilePath())) continue;
190
+ scanned++;
191
+ findings.push(...scanFieldFactories(sf));
192
+ }
193
+ return { findings, scanned };
194
+ }
195
+
196
+ function localFindings(findings: readonly Finding[]): readonly Finding[] {
197
+ return findings.filter(isLocalFinding);
198
+ }
199
+
200
+ function countByFile(findings: readonly Finding[]): Record<string, number> {
201
+ const counts: Record<string, number> = {};
202
+ for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
203
+ return counts;
204
+ }
205
+
206
+ const BASELINE_FILE = ".kumiko-pii-annotations-baseline.json";
207
+ const piiBaseline = baselineRatchet({
208
+ file: path.join(ROOT, BASELINE_FILE),
209
+ formatVersion: 1,
210
+ unit: "PII finding(s)",
211
+ });
212
+
213
+ // One place for "what goes into the baseline", used by both the compare and
214
+ // the write path — a sibling finding leaking into the written file would turn
215
+ // a foreign checkout's code into a local, unfixable red.
216
+ export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
217
+ return countByFile(localFindings(findings));
218
+ }
219
+
220
+ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
221
+ const local = localFindings(findings);
222
+ const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
223
+ return piiBaseline.check(
224
+ baselineCounts(findings),
225
+ `Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
226
+ {
227
+ formatDriftRemediation: `Run \`bun guards/guard-pii-annotations.ts --write-baseline\` once.`,
228
+ resolveLine,
229
+ },
230
+ );
231
+ }
232
+
233
+ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
234
+ const { findings } = scan(files);
235
+ if (!compareBaseline) {
236
+ console.log(" Baseline comparison skipped (--no-baseline).");
237
+ return { violations: [] };
238
+ }
239
+ return { violations: checkBaseline(findings) };
240
+ }
241
+
242
+ export const guard: AstGuard = {
243
+ name: "PII-Annotations Guard",
244
+ scan: SCAN,
245
+ hint: "after a deliberate annotation: `bun guards/guard-pii-annotations.ts --write-baseline`",
246
+ run: (files) => analyse(files, true),
247
+ };
248
+
249
+ // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
250
+ // (run-guards.ts) faehrt alle Guards mit derselben argv, ein
251
+ // --write-baseline dort duerfte die Baseline nicht stillschweigend
252
+ // neu schreiben.
253
+ if (import.meta.main) {
254
+ const args = process.argv.slice(2);
255
+ if (args.includes("--write-baseline")) {
256
+ const project = buildSharedProject([guard]);
257
+ const { findings } = scan(filesForGuard(project, guard));
258
+ piiBaseline.write(baselineCounts(findings));
259
+ process.exit(0);
260
+ }
261
+ if (args.includes("--no-baseline")) {
262
+ const project = buildSharedProject([guard]);
263
+ analyse(filesForGuard(project, guard), false);
264
+ process.exit(0);
265
+ }
266
+ runStandalone(guard);
267
+ }
@@ -188,7 +188,7 @@ export const guard: AstGuard = {
188
188
  file: "packages/framework/src/pipeline/event-log.ts",
189
189
  line: 0,
190
190
  message:
191
- "BLOCKED: pipeline/event-log.ts wurde wiederhergestellt. Die Datei gehört gelöschtihre Rolle übernimmt die events-Tabelle.",
191
+ "BLOCKED: pipeline/event-log.ts was restored. The file must be deleted the events table now takes over its role.",
192
192
  });
193
193
  }
194
194
 
@@ -50,7 +50,7 @@ const FORBIDDEN_TAGS: ReadonlyArray<{
50
50
  { tag: "td", counterpart: "<DataTable>" },
51
51
  { tag: "th", counterpart: "<DataTable>" },
52
52
  { tag: "form", counterpart: "<Form>" },
53
- { tag: "input", counterpart: "<Input> (über <Field>)" },
53
+ { tag: "input", counterpart: "<Input> (via <Field>)" },
54
54
  { tag: "button", counterpart: "<Button>" },
55
55
  { tag: "select", counterpart: "<ComboboxInput>" },
56
56
  { tag: "textarea", counterpart: '<Input kind="textarea">' },
@@ -60,7 +60,7 @@ const FORBIDDEN_TAGS: ReadonlyArray<{
60
60
  const FORBIDDEN_CALLS: ReadonlyArray<{
61
61
  readonly call: string;
62
62
  readonly counterpart: string;
63
- }> = [{ call: "alert", counterpart: "<DefaultDialog> aus @cosmicdrift/kumiko-renderer-web" }];
63
+ }> = [{ call: "alert", counterpart: "<DefaultDialog> from @cosmicdrift/kumiko-renderer-web" }];
64
64
 
65
65
  // Forbidden className tokens: an allowed tag (`<div>`), but the class gives
66
66
  // away hand-rolled primitive chrome. `bg-card` is the dedicated card-surface
@@ -263,7 +263,7 @@ function violationMessage(v: Violation): string {
263
263
  }
264
264
 
265
265
  const HINT =
266
- "Migration: usePrimitives() in eine Custom-Screen-Komponente, oder schema-driven via EntityListScreenDefinition / EntityEditScreenDefinition wo möglich. Override pro Zeile: // kumiko-lint-ignore primitives-discipline <reason>";
266
+ "Migration: usePrimitives() in a custom screen component, or schema-driven via EntityListScreenDefinition / EntityEditScreenDefinition where possible. Per-line override: // kumiko-lint-ignore primitives-discipline <reason>";
267
267
 
268
268
  export const check: RepoCheck = {
269
269
  name: "Primitives-Discipline Guard",
@@ -84,8 +84,8 @@ export const guard: AstGuard = {
84
84
  name: "Raw-ClassName Guard (App-Repos)",
85
85
  scan: SCAN,
86
86
  hint:
87
- "Design-tragende Klassen gehören in Widgets/Theme-Tokens (@cosmicdrift/kumiko-renderer-web widgets/, --color-status-*). " +
88
- `Begründete Ausnahme: // ${IGNORE_TAG} <Grund>`,
87
+ "Design-bearing classes belong in widgets/theme tokens (@cosmicdrift/kumiko-renderer-web widgets/, --color-status-*). " +
88
+ `Justified exception: // ${IGNORE_TAG} <reason>`,
89
89
  run(files: readonly SourceFile[]) {
90
90
  const violations: GuardViolation[] = [];
91
91
  for (const sf of files) {
@@ -99,7 +99,7 @@ export const guard: AstGuard = {
99
99
  violations.push({
100
100
  file: sf.getFilePath(),
101
101
  line: part.line,
102
- message: `design-tragende Tailwind-Klassen in App-Code: ${bad.join(", ")}`,
102
+ message: `design-bearing Tailwind classes in app code: ${bad.join(", ")}`,
103
103
  });
104
104
  }
105
105
  }
@@ -104,7 +104,7 @@ const BASELINE_FILE = ".kumiko-raw-interactive-elements-baseline.json";
104
104
  const rawInteractiveElementsBaseline = baselineRatchet({
105
105
  file: path.join(ROOT, BASELINE_FILE),
106
106
  formatVersion: 1,
107
- unit: "Fund(e) rohes interaktives HTML",
107
+ unit: "raw interactive HTML finding(s)",
108
108
  });
109
109
 
110
110
  const REMEDIATION =
@@ -114,14 +114,14 @@ const REMEDIATION =
114
114
  function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
115
115
  const findings = scan(files);
116
116
  if (!compareBaseline) {
117
- console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
117
+ console.log(" Baseline comparison skipped (--no-baseline).");
118
118
  return { violations: [] };
119
119
  }
120
120
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
121
121
  return {
122
122
  violations: rawInteractiveElementsBaseline.check(baselineCounts(findings), REMEDIATION, {
123
123
  formatDriftRemediation:
124
- "Einmalig `bun guards/guard-raw-interactive-elements.ts --write-baseline` aufrufen.",
124
+ "Run `bun guards/guard-raw-interactive-elements.ts --write-baseline` once.",
125
125
  resolveLine,
126
126
  }),
127
127
  };
@@ -62,8 +62,8 @@ export async function collectRawSqlFindings(
62
62
  export const check: RepoCheck = {
63
63
  name: "guard-raw-sql",
64
64
  hint:
65
- "Regel: Runtime-SQL nur in db/queries/*, bun-db/query.ts, testing/*, oder mit " +
66
- "// kumiko-lint-ignore raw-sql <Grund> auf der Zeile bzw. der Zeile davor.",
65
+ "Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
66
+ "// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
67
67
  async run(roots) {
68
68
  // kumiko-platform's deliberate empty scan-dir list must not read as vacuous (infra#610).
69
69
  const applicableRoots = roots.filter((r) => sqlScanLayoutFor(r) !== "none");
@@ -128,7 +128,7 @@ export function findViolations(file: string, root: string): Violation[] {
128
128
 
129
129
  export const check: RepoCheck = {
130
130
  name: "Renderer-Boundaries Guard",
131
- hint: "@cosmicdrift/kumiko-renderer darf keine DOM-/Browser-/Platform-APIs nutzen. Platform-spezifischer Code gehört nach @cosmicdrift/kumiko-renderer-web (oder renderer-native).",
131
+ hint: "@cosmicdrift/kumiko-renderer must not use DOM/browser/platform APIs. Platform-specific code belongs in @cosmicdrift/kumiko-renderer-web (or renderer-native).",
132
132
  run(roots) {
133
133
  const frameworkRoots = roots.filter((r) => r.kind === "framework");
134
134
  const scanDirs = frameworkRoots
@@ -112,7 +112,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
112
112
  export const guard: AstGuard = {
113
113
  name: "Restricted-Symbols Guard",
114
114
  scan: SCAN,
115
- hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} sind ein Existenz-Orakel für fremde Tenantsnur Seed-/System-interner Code darf sie referenzieren. Neuer Caller nötig? Allowlist in guard-restricted-symbols.ts erweitern, mit Begründung. Bekannte Lücke: `export * from "...event-store"` wird nicht erkannt (Named-Imports, Re-Exports und Namespace-Property-Access sind abgedeckt).',
115
+ hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants only seed-/system-internal code may reference them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
116
116
  run(files) {
117
117
  const violations: Array<{ file: string; line: number; message: string }> = [];
118
118
  const roots = resolveRepoRoots();
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bun
2
+ // Usability mistakes in screen definitions that are statically decidable and
3
+ // otherwise come back screen by screen: a metric without a label, a
4
+ // relatedList section whose rows lead nowhere, a row action labelled "edit"
5
+ // that does something else. Opt-in per app repo via _app-test.yml.
6
+
7
+ import { type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
8
+ import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
9
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
10
+
11
+ const SCAN: ScanSpec = {
12
+ scope: "source",
13
+ extensions: ["ts"],
14
+ frameworkWithin: ["packages/*/src/**"],
15
+ };
16
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
17
+ const IGNORE_TAG = "kumiko-lint-ignore screen-conventions";
18
+
19
+ const SCREEN_TYPE_SUFFIX = "ScreenDefinition";
20
+
21
+ // Anchor: only object literals that are unambiguously a screen definition —
22
+ // either a `const x: XScreenDefinition = {...}` (the house convention, see
23
+ // solon's screens.ts) or an inline literal passed straight to `r.screen(...)`.
24
+ // Everything else (unrelated object literals with a coincidental `kind`/
25
+ // `metrics` property) is deliberately left unchecked — a missed spot beats a
26
+ // false positive here.
27
+ function findScreenObjectLiterals(sf: SourceFile): ObjectLiteralExpression[] {
28
+ const roots: ObjectLiteralExpression[] = [];
29
+ for (const decl of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
30
+ const typeNode = decl.getTypeNode();
31
+ if (!typeNode?.getText().endsWith(SCREEN_TYPE_SUFFIX)) continue;
32
+ const init = decl.getInitializer();
33
+ if (init?.isKind(SyntaxKind.ObjectLiteralExpression)) roots.push(init);
34
+ }
35
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
36
+ const expr = call.getExpression();
37
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
38
+ if (expr.getName() !== "screen") continue;
39
+ const arg = call.getArguments()[0];
40
+ if (arg?.isKind(SyntaxKind.ObjectLiteralExpression)) roots.push(arg);
41
+ }
42
+ return roots;
43
+ }
44
+
45
+ function stringLiteralValue(obj: ObjectLiteralExpression, name: string): string | undefined {
46
+ const prop = obj.getProperty(name);
47
+ if (!prop?.isKind(SyntaxKind.PropertyAssignment)) return undefined;
48
+ const init = prop.getInitializer();
49
+ return init?.isKind(SyntaxKind.StringLiteral) ? init.getLiteralValue() : undefined;
50
+ }
51
+
52
+ function hasDeclaredProperty(obj: ObjectLiteralExpression, name: string): boolean {
53
+ const prop = obj.getProperty(name);
54
+ if (!prop?.isKind(SyntaxKind.PropertyAssignment)) return false;
55
+ const init = prop.getInitializer();
56
+ return init !== undefined && init.getKind() !== SyntaxKind.UndefinedKeyword;
57
+ }
58
+
59
+ // R1: MetricSpec's plain-string shorthand carries no label — every metrics
60
+ // entry on a projectionDetail screen must be the object form with `label`.
61
+ function checkMetrics(
62
+ root: ObjectLiteralExpression,
63
+ sf: SourceFile,
64
+ violations: GuardViolation[],
65
+ ): void {
66
+ const metricsProp = root.getProperty("metrics");
67
+ if (!metricsProp?.isKind(SyntaxKind.PropertyAssignment)) return;
68
+ const init = metricsProp.getInitializer();
69
+ if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) return;
70
+ for (const el of init.getElements()) {
71
+ if (hasIgnoreTag(el, IGNORE_TAG)) continue;
72
+ if (el.isKind(SyntaxKind.StringLiteral)) {
73
+ violations.push({
74
+ file: sf.getFilePath(),
75
+ line: el.getStartLineNumber(),
76
+ message: `metrics entry "${el.getLiteralValue()}" is a plain string (a number with no meaning) — use the object form with \`label\`.`,
77
+ });
78
+ continue;
79
+ }
80
+ if (el.isKind(SyntaxKind.ObjectLiteralExpression) && !hasDeclaredProperty(el, "label")) {
81
+ violations.push({
82
+ file: sf.getFilePath(),
83
+ line: el.getStartLineNumber(),
84
+ message:
85
+ "metrics entry without `label` (a number with no meaning) — the object form needs a label.",
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ // R2: a relatedList section with neither rowClick nor rowActions renders a
92
+ // list nobody can act on.
93
+ function checkRelatedListSections(
94
+ root: ObjectLiteralExpression,
95
+ sf: SourceFile,
96
+ violations: GuardViolation[],
97
+ ): void {
98
+ for (const obj of root.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
99
+ if (stringLiteralValue(obj, "kind") !== "relatedList") continue;
100
+ if (hasIgnoreTag(obj, IGNORE_TAG)) continue;
101
+ if (hasDeclaredProperty(obj, "rowClick") || hasDeclaredProperty(obj, "rowActions")) continue;
102
+ violations.push({
103
+ file: sf.getFilePath(),
104
+ line: obj.getStartLineNumber(),
105
+ message: "relatedList section without `rowClick`/`rowActions` — the row leads nowhere.",
106
+ });
107
+ }
108
+ }
109
+
110
+ // R3: a rowAction whose id isn't "edit" but whose label resolves to an
111
+ // ".action.edit" i18n key promises "Bearbeiten" while doing something else.
112
+ function checkRowActions(
113
+ root: ObjectLiteralExpression,
114
+ sf: SourceFile,
115
+ violations: GuardViolation[],
116
+ ): void {
117
+ for (const prop of root.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
118
+ const name = prop.getName();
119
+ if (name !== "rowActions" && name !== "actions") continue;
120
+ const init = prop.getInitializer();
121
+ if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) continue;
122
+ for (const el of init.getElements()) {
123
+ if (!el.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
124
+ if (hasIgnoreTag(el, IGNORE_TAG)) continue;
125
+ const id = stringLiteralValue(el, "id");
126
+ const label = stringLiteralValue(el, "label");
127
+ if (id === undefined || label === undefined) continue;
128
+ if (id !== "edit" && label.endsWith(".action.edit")) {
129
+ violations.push({
130
+ file: sf.getFilePath(),
131
+ line: el.getStartLineNumber(),
132
+ message: `rowAction "${id}" has label "${label}" (sounds like edit), but id is not "edit" — label/action mismatch.`,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ export const guard: AstGuard = {
140
+ name: "Screen-Conventions Guard",
141
+ scan: SCAN,
142
+ hint:
143
+ "metrics entries need the object form with `label`; relatedList sections need " +
144
+ '`rowClick` or `rowActions`; a rowAction label must not end in ".action.edit" ' +
145
+ 'when the `id` is not "edit". ' +
146
+ `Justified exception: // ${IGNORE_TAG} <reason>`,
147
+ run(files: readonly SourceFile[]) {
148
+ const violations: GuardViolation[] = [];
149
+ for (const sf of files) {
150
+ if (EXCLUDE.test(sf.getFilePath())) continue;
151
+ for (const root of findScreenObjectLiterals(sf)) {
152
+ checkMetrics(root, sf, violations);
153
+ checkRelatedListSections(root, sf, violations);
154
+ checkRowActions(root, sf, violations);
155
+ }
156
+ }
157
+ return { violations };
158
+ },
159
+ };
160
+
161
+ if (import.meta.main) runStandalone(guard);
@@ -158,7 +158,7 @@ function hasLeadingSkipComment(site: SkipSite): boolean {
158
158
  export const guard: AstGuard = {
159
159
  name: "Silent-Skip Guard",
160
160
  scan: SCAN,
161
- hint: "Nacktes `return;` braucht davor: Log-Call, `// skip: <grund>`-Kommentar, oder vorangehenden throw.",
161
+ hint: "A bare `return;` needs one of these before it: a log call, a `// skip: <reason>` comment, or a preceding throw.",
162
162
  run(files) {
163
163
  const violations: Array<{ file: string; line: number; message: string }> = [];
164
164