@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
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard: PII-typische Entity-Feldnamen ohne Annotation, mit Baseline-
4
- * Regression-Guard wie check-complexity.ts.
3
+ * Guard: PII-typical entity field names without annotation, with a baseline
4
+ * regression guard like check-complexity.ts.
5
5
  *
6
6
  * Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
7
7
  * fields with { pii: true }, { userOwned }, { tenantOwned: true },
8
8
  * { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
9
9
  * successor to the four legacy subject annotations).
10
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`).
11
+ * `.kumiko-pii-annotations-baseline.json` in the repo root pins the frozen
12
+ * finding count per file:
13
+ * - current <= baseline per file: PASS
14
+ * - current > baseline per file: FAIL (new unannotated PII field)
15
+ * Reductions do NOT update the baseline automatically — run `--write-baseline`
16
+ * after annotation commits. Without a baseline file the guard stays
17
+ * warning-only (bootstrap: run `--write-baseline` once).
18
18
  *
19
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
20
+ * bun guards/guard-pii-annotations.ts # compare against baseline
21
+ * bun guards/guard-pii-annotations.ts --write-baseline # rewrite baseline
22
+ * bun guards/guard-pii-annotations.ts --no-baseline # skip comparison
23
23
  */
24
24
  import * as path from "node:path";
25
25
  import {
@@ -258,10 +258,10 @@ export const guard: AstGuard = {
258
258
  writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
259
259
  };
260
260
 
261
- // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
262
- // (run-guards.ts) faehrt alle Guards mit derselben argv, ein
263
- // --write-baseline dort duerfte die Baseline nicht stillschweigend
264
- // neu schreiben.
261
+ // Flags are read ONLY here, not in run() — the shared runner
262
+ // (run-guards.ts) drives all guards with the same argv, and a
263
+ // --write-baseline there must not silently rewrite the
264
+ // baseline.
265
265
  if (import.meta.main) {
266
266
  const args = process.argv.slice(2);
267
267
  if (args.includes("--write-baseline")) {
@@ -153,6 +153,7 @@ function isWebFile(absPath: string, scopeRoot: string): boolean {
153
153
  }
154
154
 
155
155
  function walk(dir: string, out: string[]): void {
156
+ // skip: not every scope has every candidate directory
156
157
  if (!fs.existsSync(dir)) return;
157
158
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
158
159
  const full = path.join(dir, entry.name);
@@ -205,6 +206,52 @@ function hasIgnore(currentLine: string, prevLine: string): boolean {
205
206
  return lineHasIgnoreTag(currentLine, IGNORE_TAG) || lineHasIgnoreTag(prevLine, IGNORE_TAG);
206
207
  }
207
208
 
209
+ // Block-comment tracking (heuristic): `/* ... */` across multiple lines.
210
+ // Single-line `//` and `*` (inside /* */) are skipped.
211
+ function isCommentOnlyLine(trimmed: string, state: { inBlockComment: boolean }): boolean {
212
+ if (state.inBlockComment) {
213
+ if (trimmed.includes("*/")) state.inBlockComment = false;
214
+ return true;
215
+ }
216
+ if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
217
+ state.inBlockComment = true;
218
+ return true;
219
+ }
220
+ return trimmed.startsWith("//") || trimmed.startsWith("*");
221
+ }
222
+
223
+ type LineContext = Pick<Violation, "file" | "line" | "excerpt" | "scope">;
224
+
225
+ function violationsOnLine(
226
+ codeOnly: string,
227
+ context: LineContext,
228
+ publicPage: boolean,
229
+ ): Violation[] {
230
+ const violations: Violation[] = [];
231
+ for (const { tag, counterpart, pattern } of TAG_PATTERNS) {
232
+ if (pattern.test(codeOnly)) violations.push({ ...context, kind: "tag", tag, counterpart });
233
+ }
234
+ for (const { call, counterpart, pattern } of CALL_PATTERNS) {
235
+ if (pattern.test(codeOnly)) {
236
+ violations.push({ ...context, kind: "call", tag: call, counterpart });
237
+ }
238
+ }
239
+ for (const { token, counterpart, pattern } of CLASS_PATTERNS) {
240
+ if (pattern.test(codeOnly)) {
241
+ violations.push({ ...context, kind: "class", tag: token, counterpart });
242
+ }
243
+ }
244
+ if (!publicPage && MX_AUTO_PATTERN.test(codeOnly) && MAX_W_PATTERN.test(codeOnly)) {
245
+ violations.push({
246
+ ...context,
247
+ kind: "class",
248
+ tag: SCREEN_CONTAINER_LABEL,
249
+ counterpart: SCREEN_CONTAINER_COUNTERPART,
250
+ });
251
+ }
252
+ return violations;
253
+ }
254
+
208
255
  export function checkFile(
209
256
  file: string,
210
257
  scope: ScopeKind,
@@ -213,22 +260,13 @@ export function checkFile(
213
260
  const text = fs.readFileSync(file, "utf-8");
214
261
  const lines = text.split("\n");
215
262
  const violations: Violation[] = [];
216
- const publicPage = isPublicPage(path.relative(root, file));
217
- let inBlockComment = false;
263
+ const relativeFile = path.relative(root, file);
264
+ const publicPage = isPublicPage(relativeFile);
265
+ const commentState = { inBlockComment: false };
218
266
  for (let i = 0; i < lines.length; i++) {
219
267
  const line = lines[i] ?? "";
220
268
  const trimmed = line.trim();
221
- // Block-comment tracking (heuristic): `/* ... */` across multiple lines.
222
- // Single-line `//` and `*` (inside /* */) are skipped.
223
- if (inBlockComment) {
224
- if (trimmed.includes("*/")) inBlockComment = false;
225
- continue;
226
- }
227
- if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
228
- inBlockComment = true;
229
- continue;
230
- }
231
- if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
269
+ if (isCommentOnlyLine(trimmed, commentState)) continue;
232
270
  // Strip trailing inline comments — `foo(); // alert() would be wrong`
233
271
  // must not match. (?<!:) leaves URLs (https://…) in strings alone;
234
272
  // string contents themselves stay an accepted heuristic gap.
@@ -237,56 +275,14 @@ export function checkFile(
237
275
  const prev = i > 0 ? (lines[i - 1] ?? "") : "";
238
276
  if (hasIgnore(line, prev)) continue;
239
277
 
240
- for (const { tag, counterpart, pattern } of TAG_PATTERNS) {
241
- if (pattern.test(codeOnly)) {
242
- violations.push({
243
- file: path.relative(root, file),
244
- line: i + 1,
245
- kind: "tag",
246
- tag,
247
- counterpart,
248
- excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
249
- scope,
250
- });
251
- }
252
- }
253
- for (const { call, counterpart, pattern } of CALL_PATTERNS) {
254
- if (pattern.test(codeOnly)) {
255
- violations.push({
256
- file: path.relative(root, file),
257
- line: i + 1,
258
- kind: "call",
259
- tag: call,
260
- counterpart,
261
- excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
262
- scope,
263
- });
264
- }
265
- }
266
- for (const { token, counterpart, pattern } of CLASS_PATTERNS) {
267
- if (pattern.test(codeOnly)) {
268
- violations.push({
269
- file: path.relative(root, file),
270
- line: i + 1,
271
- kind: "class",
272
- tag: token,
273
- counterpart,
274
- excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
275
- scope,
276
- });
277
- }
278
- }
279
- if (!publicPage && MX_AUTO_PATTERN.test(codeOnly) && MAX_W_PATTERN.test(codeOnly)) {
280
- violations.push({
281
- file: path.relative(root, file),
282
- line: i + 1,
283
- kind: "class",
284
- tag: SCREEN_CONTAINER_LABEL,
285
- counterpart: SCREEN_CONTAINER_COUNTERPART,
286
- excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
287
- scope,
288
- });
289
- }
278
+ const excerpt = trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
279
+ violations.push(
280
+ ...violationsOnLine(
281
+ codeOnly,
282
+ { file: relativeFile, line: i + 1, excerpt, scope },
283
+ publicPage,
284
+ ),
285
+ );
290
286
  }
291
287
  return violations;
292
288
  }
@@ -306,8 +302,8 @@ export const check: RepoCheck = {
306
302
  run(roots) {
307
303
  const scopes = resolveScopes(roots);
308
304
  if (scopes.length === 0) {
309
- // infra exits 1 here ("weder FRAMEWORK_ROOT, APP_ROOT noch ein anderer
310
- // Repo-Root aufgelöst") — the vacuity floor below reproduces that.
305
+ // infra exits 1 here ("neither FRAMEWORK_ROOT, APP_ROOT nor any other
306
+ // repo root resolved") — the vacuity floor below reproduces that.
311
307
  return { violations: [], matchedFiles: 0, notApplicable: false };
312
308
  }
313
309
  // Pre-filter count (every file walk() found, before the web/-segment
@@ -64,8 +64,10 @@ function checkMetrics(
64
64
  violations: GuardViolation[],
65
65
  ): void {
66
66
  const metricsProp = root.getProperty("metrics");
67
+ // skip: screens without a literal metrics array have no entries to check
67
68
  if (!metricsProp?.isKind(SyntaxKind.PropertyAssignment)) return;
68
69
  const init = metricsProp.getInitializer();
70
+ // skip: a computed metrics value cannot be checked statically
69
71
  if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) return;
70
72
  for (const el of init.getElements()) {
71
73
  if (hasIgnoreTag(el, IGNORE_TAG)) continue;
@@ -1,34 +1,34 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Table-DDL Guard (WARNUNG, kein Fail).
3
+ * Table-DDL Guard (WARNING, no fail).
4
4
  *
5
- * Findet Aufrufe von `unsafePushTables` / `unsafeCreateEntityTable` /
6
- * `unsafeEnsureEntityTable` außerhalb der erlaubten Pfade. Apps
7
- * deklarieren Tabellen via `r.entity()` (event-sourced) oder
8
- * `r.rawTable()` (deklarativer Bypass mit Audit-Marker) — direkte
9
- * `unsafe*`-Aufrufe umgehen das Event-Sourcing-System komplett.
5
+ * Finds calls of `unsafePushTables` / `unsafeCreateEntityTable` /
6
+ * `unsafeEnsureEntityTable` outside the allowed paths. Apps
7
+ * declare tables via `r.entity()` (event-sourced) or
8
+ * `r.rawTable()` (declarative bypass with audit marker) — direct
9
+ * `unsafe*` calls bypass the event-sourcing system entirely.
10
10
  *
11
11
  * Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md
12
- * (Stufe 2). Stufe 1 hat die Symbole umbenannt damit jeder Aufruf
13
- * grep-bar ist; Stufe 3 hat `r.rawTable()` als saubere Alternative
14
- * eingeführt; Stufe 2 (dieser Check) erzwingt Konsistenz.
12
+ * (stage 2). Stage 1 renamed the symbols so every call is
13
+ * greppable; stage 3 introduced `r.rawTable()` as the clean alternative;
14
+ * stage 2 (this check) enforces consistency.
15
15
  *
16
- * Allowlist (Pfad-Regex, OR-verknüpft):
17
- * - packages/framework/src/event-store/** ES-Meta-Tabellen
16
+ * Allowlist (path regexes, OR-combined):
17
+ * - packages/framework/src/event-store/** ES meta tables
18
18
  * - packages/framework/src/pipeline/event-consumer-state.ts
19
19
  * - packages/framework/src/pipeline/projection-state.ts
20
- * - packages/framework/src/stack/** Test-Stack-Helper, pushEntityProjectionTables, Helper-Definitionen
21
- * - **\/__tests__/** Test-Setup
22
- * - **\/drizzle/** drizzle-kit-Konfiguration
23
- * - **\/bin/migrate.ts App-eigener Migrate-CLI
24
- * - packages/guards/src/guard-table-ddl.ts der Guard selbst
25
- * - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph-Rename
20
+ * - packages/framework/src/stack/** test-stack helpers, pushEntityProjectionTables, helper definitions
21
+ * - **\/__tests__/** test setup
22
+ * - **\/drizzle/** drizzle-kit configuration
23
+ * - **\/bin/migrate.ts app-owned migrate CLI
24
+ * - packages/guards/src/guard-table-ddl.ts the guard itself
25
+ * - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph rename
26
26
  *
27
- * Output: Warnung + Datei:Zeile + Code-Snippet. Blockt nie.
27
+ * Output: warning + file:line + code snippet. Never blocks.
28
28
  */
29
29
 
30
30
  import path from "node:path";
31
- import { type SourceFile, SyntaxKind } from "ts-morph";
31
+ import { type Identifier, type Node, type SourceFile, SyntaxKind } from "ts-morph";
32
32
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
33
33
 
34
34
  const ROOT = process.cwd();
@@ -49,8 +49,8 @@ export const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
49
49
  /\/__tests__\//,
50
50
  /\/drizzle\//,
51
51
  /\/bin\/migrate\.ts$/,
52
- // Guards + Rename-Script dürfen die Symbole im Code nennen — beide
53
- // sind Tooling, kein Runtime-Pfad.
52
+ // Guards + the rename script may name the symbols in code — both are
53
+ // tooling, not a runtime path.
54
54
  /\/packages\/guards\/src\/guard-table-ddl\.ts$/,
55
55
  /\/scripts\/migrate-rename-table-ddl\.ts$/,
56
56
  ];
@@ -66,6 +66,26 @@ export interface Finding {
66
66
  readonly snippet: string;
67
67
  }
68
68
 
69
+ const NEVER_CALL_SITE_PARENT_KINDS: ReadonlySet<SyntaxKind> = new Set([
70
+ SyntaxKind.ImportSpecifier,
71
+ SyntaxKind.ExportSpecifier,
72
+ SyntaxKind.PropertyAssignment,
73
+ SyntaxKind.ShorthandPropertyAssignment,
74
+ ]);
75
+
76
+ // The identifier names something other than a runtime call of the symbol: an
77
+ // import/export specifier (the violation is the call-site, not "having the
78
+ // name in scope"; the `as` alias case still flags the call), an object key,
79
+ // the right-hand side of a property access (`obj.unsafePushTables` — a method
80
+ // on an unrelated type), or a type-level member (`interface { unsafePushTables(): void }`).
81
+ function isNotRuntimeCallSite(id: Identifier, parent: Node): boolean {
82
+ if (NEVER_CALL_SITE_PARENT_KINDS.has(parent.getKind())) return true;
83
+ if (parent.isKind(SyntaxKind.PropertyAccessExpression)) return parent.getNameNode() === id;
84
+ if (parent.isKind(SyntaxKind.PropertySignature)) return parent.getNameNode() === id;
85
+ if (parent.isKind(SyntaxKind.MethodSignature)) return parent.getNameNode() === id;
86
+ return false;
87
+ }
88
+
69
89
  export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
70
90
  const absPath = sf.getFilePath();
71
91
  if (isAllowed(absPath)) return [];
@@ -77,35 +97,9 @@ export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
77
97
  const name = id.getText();
78
98
  if (!UNSAFE_NAMES.has(name)) continue;
79
99
 
80
- // Skip the import/export specifier itself — the violation is the
81
- // call-site, not "having the name in scope". An app that imports
82
- // unsafe* without calling it is weird but not the bypass we care
83
- // about (and the `as` alias case still flags the call below).
84
100
  const parent = id.getParent();
85
101
  if (!parent) continue;
86
- const pk = parent.getKind();
87
- if (pk === SyntaxKind.ImportSpecifier) continue;
88
- if (pk === SyntaxKind.ExportSpecifier) continue;
89
-
90
- // Skip property-access right-hand-side (`obj.unsafePushTables`):
91
- // a method on some unrelated type that happens to share the name.
92
- if (pk === SyntaxKind.PropertyAccessExpression) {
93
- const pae = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
94
- if (pae.getNameNode() === id) continue;
95
- }
96
- if (pk === SyntaxKind.PropertyAssignment) continue;
97
- if (pk === SyntaxKind.ShorthandPropertyAssignment) continue;
98
- // Type-level shadows: `interface { unsafePushTables(): void }` or
99
- // `type T = { unsafePushTables: () => void }`. Different namespace,
100
- // not the runtime call.
101
- if (pk === SyntaxKind.PropertySignature) {
102
- const ps = parent.asKindOrThrow(SyntaxKind.PropertySignature);
103
- if (ps.getNameNode() === id) continue;
104
- }
105
- if (pk === SyntaxKind.MethodSignature) {
106
- const ms = parent.asKindOrThrow(SyntaxKind.MethodSignature);
107
- if (ms.getNameNode() === id) continue;
108
- }
102
+ if (isNotRuntimeCallSite(id, parent)) continue;
109
103
 
110
104
  const line = id.getStartLineNumber();
111
105
  const raw = (lines[line - 1] ?? "").trim();
@@ -124,19 +118,19 @@ function report(findings: readonly Finding[], scanned: number): void {
124
118
  console.log(`Table-DDL Guard: ${scanned} files checked.`);
125
119
  if (findings.length === 0) {
126
120
  console.log(" No bypass calls outside the allowlist.");
127
- return;
128
- }
129
- console.log(` ${findings.length} bypass call(s) outside the allowlist:`);
130
- for (const f of findings) {
131
- console.log(` ${f.file}:${f.line} ${f.symbol}`);
132
- console.log(` ${f.snippet}`);
121
+ } else {
122
+ console.log(` ${findings.length} bypass call(s) outside the allowlist:`);
123
+ for (const f of findings) {
124
+ console.log(` ${f.file}:${f.line} ${f.symbol}`);
125
+ console.log(` ${f.snippet}`);
126
+ }
127
+ console.log("");
128
+ console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
129
+ console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
130
+ console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
131
+ console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
132
+ console.log(" Warning, no fail.");
133
133
  }
134
- console.log("");
135
- console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
136
- console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
137
- console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
138
- console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
139
- console.log(" Warning, no fail.");
140
134
  }
141
135
 
142
136
  export const guard: AstGuard = {
@@ -150,8 +144,8 @@ export const guard: AstGuard = {
150
144
  findings.push(...collectFindings(sf, ROOT));
151
145
  }
152
146
  report(findings, scanned);
153
- // Warnung, kein Fail (siehe Modul-Header): Bypass-Aufrufe werden
154
- // gemeldet, blocken den Guard-Run aber nie.
147
+ // Warning, no fail (see module header): bypass calls are reported but
148
+ // never block the guard run.
155
149
  return { violations: [] };
156
150
  },
157
151
  };
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  import * as path from "node:path";
42
- import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
42
+ import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
43
43
  import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
44
44
  import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
45
45
  import { TEST_FILE_RE } from "./_lib/test-file";
@@ -187,6 +187,45 @@ const PLATFORM_WIDE_ACCESS = /\baccess\.(?:systemAdmin|system|privileged)\b/;
187
187
 
188
188
  type GlobalUserWrite = { name: string; file: string; line: number };
189
189
 
190
+ function globalUserWriteMissingCheck(
191
+ call: CallExpression,
192
+ filePath: string,
193
+ ): GlobalUserWrite | undefined {
194
+ if (call.getExpression().getText() !== "defineWriteHandler") return undefined;
195
+ const arg = call.getArguments()[0];
196
+ if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
197
+ const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
198
+ // `name` is only used for the violation message, not the security
199
+ // check itself — a factory-built handler with a non-literal name
200
+ // expression (`name: enable ? "enable" : "disable"`) must not skip
201
+ // Check C just because literalStringOf() can't resolve it.
202
+ const name = literalStringOf(obj.getProperty("name"));
203
+ const schema = obj.getProperty("schema");
204
+ const accessProp = obj.getProperty("access");
205
+ // No `access` key at all = deny-all (engine/access.ts: undefined
206
+ // returns false), so there is nothing to reach across a tenant.
207
+ if (!schema || !accessProp) return undefined;
208
+ // Presets that resolve to platform-wide actors only (engine/
209
+ // config-helpers `access`): no tenant boundary exists to cross.
210
+ // Everything else — access.admin, and the openToAll handlers that
211
+ // gate on isAdminActor at runtime (#1556's shape) — is reachable by
212
+ // a tenant-scoped TenantAdmin.
213
+ if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) return undefined;
214
+ if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) return undefined;
215
+ const body = call.getText();
216
+ // Must actually touch a *user* table — otherwise a handler reading
217
+ // ctx.db.raw against an unrelated global table (plans, audit log,
218
+ // invitations) gets flagged with a "reads a global user row" message
219
+ // that doesn't apply to it (false-positive on clean code).
220
+ if (!body.includes("ctx.db.raw") || !/\buserTable\b/.test(body)) return undefined;
221
+ if (MEMBERSHIP_GATE.test(body) || CROSS_TENANT_HELPER_GATE.test(body)) return undefined;
222
+ return {
223
+ name: name ?? path.basename(filePath),
224
+ file: filePath,
225
+ line: call.getStartLineNumber(),
226
+ };
227
+ }
228
+
190
229
  export function findGlobalUserWritesMissingMembershipCheck(
191
230
  files: readonly SourceFile[],
192
231
  ): GlobalUserWrite[] {
@@ -194,39 +233,8 @@ export function findGlobalUserWritesMissingMembershipCheck(
194
233
  for (const sf of files) {
195
234
  if (TEST_FILE_RE.test(sf.getFilePath())) continue;
196
235
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
197
- if (call.getExpression().getText() !== "defineWriteHandler") continue;
198
- const arg = call.getArguments()[0];
199
- if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
200
- const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
201
- // `name` is only used for the violation message, not the security
202
- // check itself — a factory-built handler with a non-literal name
203
- // expression (`name: enable ? "enable" : "disable"`) must not skip
204
- // Check C just because literalStringOf() can't resolve it.
205
- const name = literalStringOf(obj.getProperty("name"));
206
- const schema = obj.getProperty("schema");
207
- const accessProp = obj.getProperty("access");
208
- // No `access` key at all = deny-all (engine/access.ts: undefined
209
- // returns false), so there is nothing to reach across a tenant.
210
- if (!schema || !accessProp) continue;
211
- // Presets that resolve to platform-wide actors only (engine/
212
- // config-helpers `access`): no tenant boundary exists to cross.
213
- // Everything else — access.admin, and the openToAll handlers that
214
- // gate on isAdminActor at runtime (#1556's shape) — is reachable by
215
- // a tenant-scoped TenantAdmin.
216
- if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) continue;
217
- if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) continue;
218
- const body = call.getText();
219
- // Must actually touch a *user* table — otherwise a handler reading
220
- // ctx.db.raw against an unrelated global table (plans, audit log,
221
- // invitations) gets flagged with a "reads a global user row" message
222
- // that doesn't apply to it (false-positive on clean code).
223
- if (!body.includes("ctx.db.raw") || !/\buserTable\b/.test(body)) continue;
224
- if (MEMBERSHIP_GATE.test(body) || CROSS_TENANT_HELPER_GATE.test(body)) continue;
225
- out.push({
226
- name: name ?? path.basename(sf.getFilePath()),
227
- file: sf.getFilePath(),
228
- line: call.getStartLineNumber(),
229
- });
236
+ const write = globalUserWriteMissingCheck(call, sf.getFilePath());
237
+ if (write) out.push(write);
230
238
  }
231
239
  }
232
240
  return out;
@@ -29,9 +29,9 @@ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
29
29
 
30
30
  const ROOT = process.cwd();
31
31
 
32
- // Beide Suffixe: `.integration.ts` (legacy) + `.integration.test.ts` (canonical
33
- // nach bun-test-cutover). Transition-safe — matcht main (legacy) und migrierte
34
- // Branches gleichermaßen.
32
+ // Both suffixes: `.integration.ts` (legacy) + `.integration.test.ts` (canonical
33
+ // after the bun-test cutover). Transition-safe — matches main (legacy) and
34
+ // migrated branches alike.
35
35
  const SCAN: ScanSpec = {
36
36
  scope: "tests",
37
37
  extensions: ["ts"],
@@ -66,7 +66,7 @@ export function isIntegrationTestFile(filePath: string): boolean {
66
66
 
67
67
  export interface Violation {
68
68
  file: string;
69
- reason: string;
69
+ detail: string;
70
70
  forbiddenCalls: Array<{ name: string; line: number }>;
71
71
  }
72
72
 
@@ -127,7 +127,7 @@ export function scanFile(sf: SourceFile): Violation | null {
127
127
 
128
128
  return {
129
129
  file: path.relative(ROOT, sf.getFilePath()),
130
- reason: "calls pipeline internals without buildServer/setupTestStack",
130
+ detail: "calls pipeline internals without buildServer/setupTestStack",
131
131
  forbiddenCalls: forbiddenHits,
132
132
  };
133
133
  }
@@ -156,7 +156,7 @@ export const check: RepoCheck = {
156
156
  const v = scanFile(sf);
157
157
  if (v === null) continue;
158
158
  for (const c of v.forbiddenCalls) {
159
- violations.push({ file: v.file, line: c.line, message: `${v.reason}: ${c.name}(...)` });
159
+ violations.push({ file: v.file, line: c.line, message: `${v.detail}: ${c.name}(...)` });
160
160
  }
161
161
  }
162
162
 
@@ -106,11 +106,11 @@ function isSleepStatement(node: Node): boolean {
106
106
  return false;
107
107
  }
108
108
 
109
- // Nested loops are their own finding — skipping them keeps one sleep from
110
- // being counted once per enclosing loop.
111
109
  function bodySleeps(loop: Node): boolean {
112
110
  let found = false;
113
111
  loop.forEachDescendant((node, traversal) => {
112
+ // skip: nested loops are their own finding, so one sleep is not counted
113
+ // once per enclosing loop
114
114
  if (isLoop(node)) {
115
115
  traversal.skip();
116
116
  return;
@@ -230,8 +230,9 @@ export function scanTimeouts(
230
230
  const findings: Finding[] = [];
231
231
  const add = (node: Node, message: string): void => {
232
232
  const reported = withExceptionNote(node, message);
233
- if (reported === undefined) return;
234
- findings.push({ file, line: node.getStartLineNumber(), message: reported });
233
+ if (reported !== undefined) {
234
+ findings.push({ file, line: node.getStartLineNumber(), message: reported });
235
+ }
235
236
  };
236
237
  const aliases = testFrameworkAliases(sf);
237
238
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -1,27 +1,27 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard: `createTextField`/`createLongTextField`-Aufrufe ohne `personal`-
4
- * Haltung, mit Baseline-Regression-Guard wie guard-pii-annotations.ts.
3
+ * Guard: `createTextField`/`createLongTextField` calls without a `personal`
4
+ * stance, with a baseline regression guard like guard-pii-annotations.ts.
5
5
  *
6
- * kumiko-framework#2810: `createTextField`/`createLongTextField` sollen
7
- * fail-closed werfen, wenn kein `personal` (Muster #2558) deklariert ist.
8
- * Workspace-weit fehlt `personal` an hunderten Call-Sites — ein Throw würde
9
- * den Build sofort brechen. Dieser Guard deckt die Vollmenge (jeder Aufruf)
10
- * ab, `guard-pii-annotations.ts` nur die Namens-Heuristik (PII-verdächtige
11
- * Feldnamen) — die beiden Baselines sind bewusst getrennt.
6
+ * kumiko-framework#2810: `createTextField`/`createLongTextField` should
7
+ * throw fail-closed when no `personal` (pattern #2558) is declared.
8
+ * Workspace-wide `personal` is missing at hundreds of call sites — a throw would
9
+ * break the build immediately. This guard covers the full set (every call),
10
+ * `guard-pii-annotations.ts` only the name heuristic (PII-suspicious
11
+ * field names) — the two baselines are deliberately separate.
12
12
  *
13
- * `.kumiko-text-field-stance-baseline.json` im Repo-Root pinnt pro File die
14
- * eingefrorene Fund-Anzahl:
15
- * - aktuell <= baseline pro File: PASS
16
- * - aktuell > baseline pro File: FAIL (neuer Aufruf ohne personal-Haltung)
17
- * Reduktionen updaten die Baseline NICHT automatisch — nach Annotations-
18
- * Commits `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Guard
19
- * warning-only (Bootstrap: einmalig `--write-baseline`).
13
+ * `.kumiko-text-field-stance-baseline.json` in the repo root pins the frozen
14
+ * finding count per file:
15
+ * - current <= baseline per file: PASS
16
+ * - current > baseline per file: FAIL (new call without a personal stance)
17
+ * Reductions do NOT update the baseline automatically — run `--write-baseline`
18
+ * after annotation commits. Without a baseline file the guard stays
19
+ * warning-only (bootstrap: run `--write-baseline` once).
20
20
  *
21
21
  * Usage:
22
- * bun guards/guard-text-field-stance.ts # Vergleich gegen Baseline
23
- * bun guards/guard-text-field-stance.ts --write-baseline # Baseline neu schreiben
24
- * bun guards/guard-text-field-stance.ts --no-baseline # Vergleich überspringen
22
+ * bun guards/guard-text-field-stance.ts # compare against baseline
23
+ * bun guards/guard-text-field-stance.ts --write-baseline # rewrite baseline
24
+ * bun guards/guard-text-field-stance.ts --no-baseline # skip comparison
25
25
  */
26
26
  import * as path from "node:path";
27
27
  import {
@@ -227,10 +227,10 @@ export const guard: AstGuard = {
227
227
  writeBaseline: (files) => textFieldStanceBaseline.write(baselineCounts(scan(files).findings)),
228
228
  };
229
229
 
230
- // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
231
- // (run-guards.ts) faehrt alle Guards mit derselben argv, ein
232
- // --write-baseline dort duerfte die Baseline nicht stillschweigend
233
- // neu schreiben.
230
+ // Flags are read ONLY here, not in run() — the shared runner
231
+ // (run-guards.ts) drives all guards with the same argv, and a
232
+ // --write-baseline there must not silently rewrite the
233
+ // baseline.
234
234
  if (import.meta.main) {
235
235
  const args = process.argv.slice(2);
236
236
  if (args.includes("--write-baseline")) {