@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
@@ -111,7 +111,7 @@ export const guard: AstGuard = {
111
111
  scan: SCAN,
112
112
  // App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
113
113
  security: true,
114
- hint: "Admin-API (appendRaw/appendRawBatch) umgeht die Pipelineerlaubt nur in samples/*/migration/ oder scripts/migrations/. Für Domain-Events: ctx.appendEvent / write-Handler.",
114
+ hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipelineonly allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
115
115
  run(files) {
116
116
  const violations: Array<{ file: string; line: number; message: string }> = [];
117
117
 
@@ -0,0 +1,114 @@
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.
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.
13
+ //
14
+ // Teil von App-Mounting 2.0 (infra#208).
15
+
16
+ import * as path from "node:path";
17
+ import { type SourceFile, SyntaxKind } from "ts-morph";
18
+ import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
19
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
20
+
21
+ const SCAN: ScanSpec = {
22
+ scope: "source",
23
+ extensions: ["ts", "tsx"],
24
+ within: ["features/**"],
25
+ frameworkWithin: ["packages/bundled-features/src/**"],
26
+ };
27
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
28
+ const IGNORE_TAG = "kumiko-lint-ignore app-feature-structure";
29
+
30
+ const MAX_FEATURE_TS_LINES = 300;
31
+
32
+ // src/features/<name>/<file> bzw. packages/bundled-features/src/<name>/<file>
33
+ // — genau eine Ebene unter dem Feature-Ordner.
34
+ function isFeatureRootFile(filePath: string): boolean {
35
+ const m = filePath.match(/(src\/features|packages\/bundled-features\/src)\/[^/]+\/[^/]+$/);
36
+ return m !== null;
37
+ }
38
+
39
+ export const guard: AstGuard = {
40
+ name: "App-Feature-Structure Guard (App-Repos)",
41
+ scan: SCAN,
42
+ hint:
43
+ "Konvention: feature.ts nur Registrierung, Screens unter web/ (eine Datei pro Screen), Handler unter handlers/, " +
44
+ `Domain-Logik unter lib/. Custom-Screens brauchen // ${IGNORE_TAG} <Grund> (deklarative Screen-Typen sind der Default).`,
45
+ run(files: readonly SourceFile[]) {
46
+ const violations: GuardViolation[] = [];
47
+ for (const sf of files) {
48
+ const filePath = sf.getFilePath();
49
+ if (EXCLUDE.test(filePath)) continue;
50
+ 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 am Feature-Root — Screens/Client-Def gehören unter web/ (index.ts + eine Datei pro 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-Komponente am Feature-Root — unter web/ verschieben",
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 hat ${lines} Zeilen (max ${MAX_FEATURE_TS_LINES}) — Handler nach handlers/, Schemas nach schema/, Logik nach 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
+ if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
103
+ violations.push({
104
+ file: filePath,
105
+ line: prop.getStartLineNumber(),
106
+ message: `r.screen type:"custom" ohne Allowlist-Tag — deklarativen Screen-Typ nutzen oder // ${IGNORE_TAG} <Grund>`,
107
+ });
108
+ }
109
+ }
110
+ return { violations };
111
+ },
112
+ };
113
+
114
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard R8: verbietet `broker.subscribe(...)` ausserhalb des Frameworks.
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:
7
+ * - `r.onEvent("event-name", handler)`
8
+ * - `r.job({ trigger: { on: "event-name" }, handler })`
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.
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.
22
+ *
23
+ * Ausnahme: `packages/framework/src/pipeline/**` — dort lebt das Broker-
24
+ * Plumbing selbst (framework-intern, erlaubt).
25
+ *
26
+ * Usage: bun guards/guard-broker-subscribe.ts
27
+ * Exit 1 bei Fund, 0 wenn sauber.
28
+ */
29
+
30
+ import * as path from "node:path";
31
+ import { type SourceFile, SyntaxKind } from "ts-morph";
32
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
33
+
34
+ const ROOT = process.cwd();
35
+
36
+ const SCAN: ScanSpec = {
37
+ scope: "source",
38
+ extensions: ["ts"],
39
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
40
+ };
41
+
42
+ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|\.g\.ts$)/;
43
+
44
+ // Broker-Plumbing selbst — framework-intern, darf subscriben.
45
+ const ALLOW = /(^|\/)packages\/framework\/src\/pipeline\//;
46
+
47
+ const BROKER_RECEIVER = /^(event)?broker$/i;
48
+
49
+ export function isAllowed(filePath: string): boolean {
50
+ return ALLOW.test(path.relative(ROOT, filePath));
51
+ }
52
+
53
+ /** Terminaler Bezeichner einer Receiver-Expression: `ctx.eventBroker` → "eventBroker". */
54
+ function receiverName(node: import("ts-morph").Node): string | undefined {
55
+ if (node.getKind() === SyntaxKind.Identifier) return node.getText();
56
+ const pae = node.asKind(SyntaxKind.PropertyAccessExpression);
57
+ return pae?.getName();
58
+ }
59
+
60
+ export function collectBrokerSubscribeViolations(
61
+ sf: SourceFile,
62
+ ): Array<{ line: number; message: string }> {
63
+ const hits: Array<{ line: number; message: string }> = [];
64
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
65
+ const callee = call.getExpression();
66
+ const pae = callee.asKind(SyntaxKind.PropertyAccessExpression);
67
+ if (!pae || pae.getName() !== "subscribe") continue;
68
+ const recv = receiverName(pae.getExpression());
69
+ if (!recv || !BROKER_RECEIVER.test(recv)) continue;
70
+ hits.push({
71
+ line: call.getStartLineNumber(),
72
+ message: `[${recv}.subscribe] ${recv}.subscribe(...) — use r.onEvent(...) or r.job({ trigger: { on } })`,
73
+ });
74
+ }
75
+ return hits;
76
+ }
77
+
78
+ export const guard: AstGuard = {
79
+ name: "No-Broker-Subscribe Guard",
80
+ scan: SCAN,
81
+ hint: "Consume events through the registrar API (r.onEvent / r.job trigger.on), not directly on the broker — see docs/plans/architecture/lint-rules.md (R8).",
82
+ run(files) {
83
+ const violations: Array<{ file: string; line: number; message: string }> = [];
84
+ for (const sf of files) {
85
+ const file = sf.getFilePath();
86
+ if (EXCLUDE.test(file) || isAllowed(file)) continue;
87
+ for (const hit of collectBrokerSubscribeViolations(sf)) {
88
+ violations.push({
89
+ file: path.relative(ROOT, file),
90
+ line: hit.line,
91
+ message: hit.message,
92
+ });
93
+ }
94
+ }
95
+ return { violations };
96
+ },
97
+ };
98
+
99
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: `details.reason` strings and the first arg to UnprocessableError /
4
+ * failUnprocessable must follow the reason convention:
5
+ *
6
+ * ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$
7
+ *
8
+ * That is: lowercase ASCII, underscores for word breaks, optional dot
9
+ * namespaces for feature-scoped reasons (e.g. `order.already_cancelled`).
10
+ * No spaces, no camelCase, no dashes, no leading digits.
11
+ *
12
+ * Why: reason codes survive the wire + logs + i18n lookup. Clients key off
13
+ * them. Any drift (camelCase, typos like `stale_stat`) means a missed
14
+ * branch in the SDK. Catching it at commit time is dramatically cheaper
15
+ * than discovering a client-side dead branch in prod.
16
+ *
17
+ * What this checks:
18
+ * 1. `new UnprocessableError(X, ...)` — X must be a string-literal reason
19
+ * that matches the regex, OR a reference to a known Reasons const
20
+ * (FrameworkReasons.*, <Anything>Reasons.*, TenantErrors.*, etc.).
21
+ * 2. `failUnprocessable(X, ...)` — same rule.
22
+ * 3. Object literals containing `reason: "X"` — same rule for the X.
23
+ * Skips `openToAll: { reason: "..." }` / `escapeHatch: { reason: "..." }`
24
+ * — those are prose access-declaration justifications, enforced instead
25
+ * by guard-open-to-all-reason.ts / guard-escape-hatch-declared.ts.
26
+ *
27
+ * Non-literal reasons (computed, template strings with interpolation,
28
+ * identifier references) are assumed to be typed-from-a-const and pass.
29
+ * A stricter version could walk to the declaration; v1 stays pragmatic.
30
+ *
31
+ * Usage:
32
+ * bun guards/guard-error-reasons.ts
33
+ */
34
+
35
+ import * as path from "node:path";
36
+ import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
37
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
38
+
39
+ const ROOT = process.cwd();
40
+
41
+ const SCAN: ScanSpec = {
42
+ scope: "source",
43
+ extensions: ["ts"],
44
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
45
+ };
46
+
47
+ // Excluded: test files (they may legitimately fabricate broken reasons to
48
+ // prove the guard catches them) and the classes.ts / reasons.ts definitions
49
+ // themselves (message text in constructor defaults isn't a reason).
50
+ const EXCLUDE =
51
+ /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|errors\/classes\.ts$|errors\/reasons\.ts$|node_modules)/;
52
+
53
+ const REASON_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
54
+
55
+ // Calls whose first positional arg is a reason string.
56
+ const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
57
+
58
+ // Property names whose `{ reason: "..." }` is a prose access-declaration
59
+ // justification, not an error-reason code.
60
+ const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
61
+
62
+ interface Violation {
63
+ readonly file: string;
64
+ readonly line: number;
65
+ readonly kind: "unproc-arg" | "details-reason";
66
+ readonly value: string;
67
+ }
68
+
69
+ function scanFile(sf: SourceFile): Violation[] {
70
+ const violations: Violation[] = [];
71
+
72
+ // ---------- (1) + (2): UnprocessableError / failUnprocessable calls ----------
73
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
74
+ const callee = call.getExpression();
75
+ // Strip `new `, drop qualifying module access. We only need the final
76
+ // identifier for the call name check.
77
+ const text = callee.getText();
78
+ const name = text.split(".").pop() ?? text;
79
+
80
+ // Also catch `new UnprocessableError(...)` — ts-morph models that as a
81
+ // NewExpression, but class-ref-as-callable in our codebase is exercised
82
+ // only in tests/internal, so the NewExpression pass below handles it.
83
+ if (!UNPROC_CALL_NAMES.has(name)) continue;
84
+
85
+ const arg = call.getArguments()[0];
86
+ const bad = checkReasonNode(arg);
87
+ if (bad !== null) {
88
+ violations.push({
89
+ file: path.relative(ROOT, sf.getFilePath()),
90
+ line: call.getStartLineNumber(),
91
+ kind: "unproc-arg",
92
+ value: bad,
93
+ });
94
+ }
95
+ }
96
+
97
+ for (const neu of sf.getDescendantsOfKind(SyntaxKind.NewExpression)) {
98
+ const callee = neu.getExpression();
99
+ const text = callee.getText();
100
+ const name = text.split(".").pop() ?? text;
101
+ if (name !== "UnprocessableError") continue;
102
+
103
+ const arg = neu.getArguments()[0];
104
+ const bad = checkReasonNode(arg);
105
+ if (bad !== null) {
106
+ violations.push({
107
+ file: path.relative(ROOT, sf.getFilePath()),
108
+ line: neu.getStartLineNumber(),
109
+ kind: "unproc-arg",
110
+ value: bad,
111
+ });
112
+ }
113
+ }
114
+
115
+ // ---------- (3): object literals with `reason: "..."` ----------
116
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
117
+ const name = prop.getName();
118
+ if (name !== "reason") continue;
119
+ if (isAccessDeclarationReason(prop)) continue;
120
+
121
+ const initializer = prop.getInitializer();
122
+ const bad = checkReasonNode(initializer);
123
+ if (bad !== null) {
124
+ violations.push({
125
+ file: path.relative(ROOT, sf.getFilePath()),
126
+ line: prop.getStartLineNumber(),
127
+ kind: "details-reason",
128
+ value: bad,
129
+ });
130
+ }
131
+ }
132
+
133
+ return violations;
134
+ }
135
+
136
+ // A `reason` PropertyAssignment whose object literal is the initializer of
137
+ // an `openToAll` or `escapeHatch` PropertyAssignment is an access-declaration
138
+ // justification, not an error-reason code.
139
+ export function isAccessDeclarationReason(prop: Node): boolean {
140
+ const objectLiteral = prop.getParent();
141
+ if (!objectLiteral?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
142
+ const owner = objectLiteral.getParent();
143
+ if (!owner?.isKind(SyntaxKind.PropertyAssignment)) return false;
144
+ return ACCESS_DECLARATION_NAMES.has(owner.getName());
145
+ }
146
+
147
+ // Returns the offending string if this node is a string literal that does
148
+ // NOT match the reason regex; null otherwise (including for non-literals —
149
+ // those are assumed to come from a typed const and slip through).
150
+ function checkReasonNode(node: Node | undefined): string | null {
151
+ if (!node) return null;
152
+ if (node.isKind(SyntaxKind.StringLiteral)) {
153
+ const lit = node.getLiteralText();
154
+ return REASON_RE.test(lit) ? null : lit;
155
+ }
156
+ if (node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
157
+ const lit = node.getLiteralText();
158
+ return REASON_RE.test(lit) ? null : lit;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ export const guard: AstGuard = {
164
+ name: "Error-Reasons Guard",
165
+ scan: SCAN,
166
+ hint: `reason strings must match ${REASON_RE} (snake_case ASCII, optional dot-namespaced). Reusable? Add a const to FrameworkReasons/<Feature>Reasons.`,
167
+ run(files) {
168
+ const violations: Array<{ file: string; line: number; message: string }> = [];
169
+ for (const sf of files) {
170
+ if (EXCLUDE.test(sf.getFilePath())) continue;
171
+ for (const v of scanFile(sf)) {
172
+ const where =
173
+ v.kind === "unproc-arg" ? "UnprocessableError/failUnprocessable" : "details.reason";
174
+ violations.push({
175
+ file: v.file,
176
+ line: v.line,
177
+ message: `${where} "${v.value}"`,
178
+ });
179
+ }
180
+ }
181
+ return { violations };
182
+ },
183
+ };
184
+
185
+ if (import.meta.main) runStandalone(guard);
@@ -6,8 +6,8 @@
6
6
  * R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
7
7
  * `r.systemScope()` feature.
8
8
  * R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
9
- * systemScope and outside a handler/hook that lexically declares
10
- * `escapeHatch`.
9
+ * systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, or a
10
+ * handler/hook that lexically declares `escapeHatch`.
11
11
  * R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
12
12
  * with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
13
13
  * call, a `*Job` function, or a handler/hook that lexically declares
@@ -22,11 +22,13 @@
22
22
  *
23
23
  * escapeHatch (R2/R3) is recognized only as a direct, literal `escapeHatch`
24
24
  * property (object literal, or ternary of two object literals) either in the
25
- * same object literal as an inline `handler` function, or in an options object
26
- * passed to `r.hook`/`writeHandler`/`queryHandler`/`streamHandler` alongside
27
- * the handler function argument. Referenced-by-variable functions, spread
28
- * options, computed/string keys, and non-literal escapeHatch values are
29
- * conservatively not recognized (miss, don't falsely clear).
25
+ * same object literal as any inline function property (ArrowFunction or
26
+ * FunctionExpression, under any key name — e.g. `handler`, `export`,
27
+ * `delete`), or in an options object passed to
28
+ * `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
29
+ * alongside the handler function argument. Referenced-by-variable functions,
30
+ * spread options, computed/string keys, and non-literal escapeHatch values
31
+ * are conservatively not recognized (miss, don't falsely clear).
30
32
  *
31
33
  * Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
32
34
  * job (access-declarations.ts), not this guard's. Known false-negatives:
@@ -174,7 +176,7 @@ function findUnsafeRawFindings(
174
176
  const expr = call.getExpression();
175
177
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
176
178
  if (expr.getName() !== "unsafeRaw") continue;
177
- if (isInsideEscapeHatchDeclaredFunction(call)) continue;
179
+ if (isAllowedEscapeHatchCall(call, sf, systemDirs) || isExplicitUnsafeRawGrant(call)) continue;
178
180
  out.push({
179
181
  file: path.relative(root, sf.getFilePath()),
180
182
  line: call.getStartLineNumber(),
@@ -186,6 +188,19 @@ function findUnsafeRawFindings(
186
188
  return out;
187
189
  }
188
190
 
191
+ function isExplicitUnsafeRawGrant(call: Node): boolean {
192
+ if (!call.isKind(SyntaxKind.CallExpression)) return false;
193
+ const expr = call.getExpression();
194
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression) || expr.getName() !== "unsafeRaw") {
195
+ return false;
196
+ }
197
+ const receiver = expr.getExpression();
198
+ return (
199
+ receiver.isKind(SyntaxKind.CallExpression) &&
200
+ receiver.getExpression().getText() === "withUnsafeRawGrant"
201
+ );
202
+ }
203
+
189
204
  function isSystemIdentityExpression(node: Node): boolean {
190
205
  if (node.isKind(SyntaxKind.CallExpression)) {
191
206
  const calleeText = node.getExpression().getText();
@@ -270,6 +285,7 @@ const ESCAPE_HATCH_CALL_METHODS = new Set([
270
285
  "writeHandler",
271
286
  "queryHandler",
272
287
  "streamHandler",
288
+ "useExtension",
273
289
  ]);
274
290
 
275
291
  function isEscapeHatchDeclaredFunction(fn: Node): boolean {
@@ -277,13 +293,7 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
277
293
  if (!parent) return false;
278
294
 
279
295
  if (fn.isKind(SyntaxKind.MethodDeclaration)) {
280
- const nameNode = fn.getNameNode();
281
- return (
282
- nameNode.isKind(SyntaxKind.Identifier) &&
283
- nameNode.getText() === "handler" &&
284
- parent.isKind(SyntaxKind.ObjectLiteralExpression) &&
285
- objectDeclaresEscapeHatch(parent)
286
- );
296
+ return parent.isKind(SyntaxKind.ObjectLiteralExpression) && objectDeclaresEscapeHatch(parent);
287
297
  }
288
298
 
289
299
  if (!fn.isKind(SyntaxKind.ArrowFunction) && !fn.isKind(SyntaxKind.FunctionExpression)) {
@@ -291,11 +301,8 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
291
301
  }
292
302
 
293
303
  if (parent.isKind(SyntaxKind.PropertyAssignment)) {
294
- const nameNode = parent.getNameNode();
295
304
  const obj = parent.getParent();
296
305
  return (
297
- nameNode.isKind(SyntaxKind.Identifier) &&
298
- nameNode.getText() === "handler" &&
299
306
  parent.getInitializer() === fn &&
300
307
  obj.isKind(SyntaxKind.ObjectLiteralExpression) &&
301
308
  objectDeclaresEscapeHatch(obj)
@@ -493,7 +500,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
493
500
  name: "Escape-Hatch-Declared Guard",
494
501
  scan: SCAN,
495
502
  security: true,
496
- hint: 'Escape-Hatch deklarieren (r.systemScope() auf der Feature-Definition, .job.ts/r.job(...) fuer Jobs, oder { escapeHatch: { reason: "..." } } auf dem Handler oder Hook) oder den ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants-Zugriff entfernen. Baseline nach bewusster Reduktion: `bun guards/run-guards.ts --write-security-baseline`',
503
+ hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `bun guards/run-guards.ts --write-security-baseline`',
497
504
  run(files) {
498
505
  const violations: GuardViolation[] = [
499
506
  ...findGenericReasonCalls(files, opts.root).map((f) => ({
@@ -117,7 +117,7 @@ function scanFile(sf: SourceFile): Violation[] {
117
117
  export const guard: AstGuard = {
118
118
  name: "Fake-Test Guard",
119
119
  scan: SCAN,
120
- hint: "Test ohne expect() oder mit Tautologieechtes Verhalten prüfen, nicht Existenz.",
120
+ hint: "Test without expect() or with a tautology check real behavior, not existence.",
121
121
  run(files) {
122
122
  const violations: Array<{ file: string; line: number; message: string }> = [];
123
123
  for (const sf of files) {