@cosmicdrift/kumiko-guards 0.1.1 → 0.281.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 (52) hide show
  1. package/package.json +5 -2
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +193 -0
  16. package/src/guard-escape-hatch-declared.ts +155 -35
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. 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,193 @@
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
+ * and `declareEscapeHatch({ reason: "..." })` — those are prose
25
+ * access-declaration justifications, enforced instead by
26
+ * guard-open-to-all-reason.ts / guard-escape-hatch-declared.ts.
27
+ *
28
+ * Non-literal reasons (computed, template strings with interpolation,
29
+ * identifier references) are assumed to be typed-from-a-const and pass.
30
+ * A stricter version could walk to the declaration; v1 stays pragmatic.
31
+ *
32
+ * Usage:
33
+ * bun guards/guard-error-reasons.ts
34
+ */
35
+
36
+ import * as path from "node:path";
37
+ import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
38
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
39
+
40
+ const ROOT = process.cwd();
41
+
42
+ const SCAN: ScanSpec = {
43
+ scope: "source",
44
+ extensions: ["ts"],
45
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
46
+ };
47
+
48
+ // Excluded: test files (they may legitimately fabricate broken reasons to
49
+ // prove the guard catches them) and the classes.ts / reasons.ts definitions
50
+ // themselves (message text in constructor defaults isn't a reason).
51
+ const EXCLUDE =
52
+ /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|errors\/classes\.ts$|errors\/reasons\.ts$|node_modules)/;
53
+
54
+ const REASON_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
55
+
56
+ // Calls whose first positional arg is a reason string.
57
+ const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
58
+
59
+ // Property names whose `{ reason: "..." }` is a prose access-declaration
60
+ // justification, not an error-reason code.
61
+ const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
62
+
63
+ interface Violation {
64
+ readonly file: string;
65
+ readonly line: number;
66
+ readonly kind: "unproc-arg" | "details-reason";
67
+ readonly value: string;
68
+ }
69
+
70
+ function scanFile(sf: SourceFile): Violation[] {
71
+ const violations: Violation[] = [];
72
+
73
+ // ---------- (1) + (2): UnprocessableError / failUnprocessable calls ----------
74
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
75
+ const callee = call.getExpression();
76
+ // Strip `new `, drop qualifying module access. We only need the final
77
+ // identifier for the call name check.
78
+ const text = callee.getText();
79
+ const name = text.split(".").pop() ?? text;
80
+
81
+ // Also catch `new UnprocessableError(...)` — ts-morph models that as a
82
+ // NewExpression, but class-ref-as-callable in our codebase is exercised
83
+ // only in tests/internal, so the NewExpression pass below handles it.
84
+ if (!UNPROC_CALL_NAMES.has(name)) continue;
85
+
86
+ const arg = call.getArguments()[0];
87
+ const bad = checkReasonNode(arg);
88
+ if (bad !== null) {
89
+ violations.push({
90
+ file: path.relative(ROOT, sf.getFilePath()),
91
+ line: call.getStartLineNumber(),
92
+ kind: "unproc-arg",
93
+ value: bad,
94
+ });
95
+ }
96
+ }
97
+
98
+ for (const neu of sf.getDescendantsOfKind(SyntaxKind.NewExpression)) {
99
+ const callee = neu.getExpression();
100
+ const text = callee.getText();
101
+ const name = text.split(".").pop() ?? text;
102
+ if (name !== "UnprocessableError") continue;
103
+
104
+ const arg = neu.getArguments()[0];
105
+ const bad = checkReasonNode(arg);
106
+ if (bad !== null) {
107
+ violations.push({
108
+ file: path.relative(ROOT, sf.getFilePath()),
109
+ line: neu.getStartLineNumber(),
110
+ kind: "unproc-arg",
111
+ value: bad,
112
+ });
113
+ }
114
+ }
115
+
116
+ // ---------- (3): object literals with `reason: "..."` ----------
117
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
118
+ const name = prop.getName();
119
+ if (name !== "reason") continue;
120
+ if (isAccessDeclarationReason(prop)) continue;
121
+
122
+ const initializer = prop.getInitializer();
123
+ const bad = checkReasonNode(initializer);
124
+ if (bad !== null) {
125
+ violations.push({
126
+ file: path.relative(ROOT, sf.getFilePath()),
127
+ line: prop.getStartLineNumber(),
128
+ kind: "details-reason",
129
+ value: bad,
130
+ });
131
+ }
132
+ }
133
+
134
+ return violations;
135
+ }
136
+
137
+ // A `reason` PropertyAssignment whose object literal is the initializer of
138
+ // an `openToAll` / `escapeHatch` PropertyAssignment, or the sole argument
139
+ // object of a bare `declareEscapeHatch(...)` call, is an access-declaration
140
+ // justification, not an error-reason code.
141
+ export function isAccessDeclarationReason(prop: Node): boolean {
142
+ const objectLiteral = prop.getParent();
143
+ if (!objectLiteral?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
144
+ const owner = objectLiteral.getParent();
145
+ if (owner?.isKind(SyntaxKind.PropertyAssignment)) {
146
+ return ACCESS_DECLARATION_NAMES.has(owner.getName());
147
+ }
148
+ if (owner?.isKind(SyntaxKind.CallExpression)) {
149
+ const callee = owner.getExpression();
150
+ return callee.isKind(SyntaxKind.Identifier) && callee.getText() === "declareEscapeHatch";
151
+ }
152
+ return false;
153
+ }
154
+
155
+ // Returns the offending string if this node is a string literal that does
156
+ // NOT match the reason regex; null otherwise (including for non-literals —
157
+ // those are assumed to come from a typed const and slip through).
158
+ function checkReasonNode(node: Node | undefined): string | null {
159
+ if (!node) return null;
160
+ if (node.isKind(SyntaxKind.StringLiteral)) {
161
+ const lit = node.getLiteralText();
162
+ return REASON_RE.test(lit) ? null : lit;
163
+ }
164
+ if (node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
165
+ const lit = node.getLiteralText();
166
+ return REASON_RE.test(lit) ? null : lit;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ export const guard: AstGuard = {
172
+ name: "Error-Reasons Guard",
173
+ scan: SCAN,
174
+ hint: `reason strings must match ${REASON_RE} (snake_case ASCII, optional dot-namespaced). Reusable? Add a const to FrameworkReasons/<Feature>Reasons.`,
175
+ run(files) {
176
+ const violations: Array<{ file: string; line: number; message: string }> = [];
177
+ for (const sf of files) {
178
+ if (EXCLUDE.test(sf.getFilePath())) continue;
179
+ for (const v of scanFile(sf)) {
180
+ const where =
181
+ v.kind === "unproc-arg" ? "UnprocessableError/failUnprocessable" : "details.reason";
182
+ violations.push({
183
+ file: v.file,
184
+ line: v.line,
185
+ message: `${where} "${v.value}"`,
186
+ });
187
+ }
188
+ }
189
+ return { violations };
190
+ },
191
+ };
192
+
193
+ if (import.meta.main) runStandalone(guard);