@cosmicdrift/kumiko-guards 0.381.0 → 0.383.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.
@@ -1,139 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: blocks calls of `loadAllEventsByType()` outside tests,
4
- * ops scripts and the definition itself.
5
- *
6
- * `loadAllEventsByType` buffers ALL events of an aggregate_type in
7
- * memory (a `SELECT … ORDER BY` without a limit). Beyond ~100k events per
8
- * type that is an OOM cliff that only shows under prod load — exactly the
9
- * kind of silent-and-late failure a guard catches before it ships.
10
- *
11
- * The memory-bounded replacement is `streamAllEventsByType` (yields in
12
- * batches, never more than batchSize rows resident). Production projection
13
- * rebuild already goes through this streaming path. `loadAllEventsByType` stays legitimate
14
- * for tests (small, controlled stores) and ops scripts on known
15
- * small aggregate_types — hence the allowlist instead of a removal.
16
- *
17
- * Usage:
18
- * bun packages/guards/src/guard-loadall-events.ts
19
- */
20
-
21
- import * as path from "node:path";
22
- import { type CallExpression, type SourceFile, SyntaxKind } from "ts-morph";
23
- import {
24
- type AstGuard,
25
- type GuardViolation,
26
- isLocalFinding,
27
- runStandalone,
28
- type ScanSpec,
29
- } from "./_lib/guard-kit";
30
-
31
- const ROOT = process.cwd();
32
-
33
- const SCAN: ScanSpec = {
34
- scope: "source",
35
- extensions: ["ts"],
36
- kinds: ["framework", "library"],
37
- };
38
-
39
- // Test files may use the API freely — they are the primary verifiers and
40
- // run against small, controlled stores.
41
- const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
42
-
43
- // Allowed callers: the definition itself + ops/migration scripts (run
44
- // on known small aggregate_types, not in the prod hot path).
45
- // ROOT is anchored to process.cwd() (repo root), the `^` anchor only matches
46
- // real top-level scripts/, not packages/.../scripts/*.
47
- const ALLOWLIST: readonly RegExp[] = [
48
- /^packages\/framework\/src\/event-store\/event-store\.ts$/,
49
- /^scripts\//,
50
- ];
51
-
52
- const GUARDED_CALLS = new Set(["loadAllEventsByType"]);
53
-
54
- export interface Violation {
55
- file: string;
56
- line: number;
57
- functionName: string;
58
- enclosingFunction: string;
59
- }
60
-
61
- export function isAllowed(relativePath: string): boolean {
62
- return ALLOWLIST.some((re) => re.test(relativePath));
63
- }
64
-
65
- export function collectViolations(sourceFile: SourceFile): Violation[] {
66
- const violations: Violation[] = [];
67
- const relativePath = path.relative(ROOT, sourceFile.getFilePath());
68
- if (isAllowed(relativePath)) return violations;
69
-
70
- const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
71
- for (const call of calls) {
72
- const fnName = getCalleeName(call);
73
- if (!fnName || !GUARDED_CALLS.has(fnName)) continue;
74
- violations.push({
75
- file: relativePath,
76
- line: call.getStartLineNumber(),
77
- functionName: fnName,
78
- enclosingFunction: findEnclosingName(call),
79
- });
80
- }
81
- return violations;
82
- }
83
-
84
- // Extract the called name. Handles:
85
- // loadAllEventsByType(...) → "loadAllEventsByType" (Identifier)
86
- // someNamespace.loadAllEventsByType(...) → "loadAllEventsByType" (PropertyAccess)
87
- // Any other callee shape returns null.
88
- function getCalleeName(call: CallExpression): string | null {
89
- const expr = call.getExpression();
90
- if (expr.getKind() === SyntaxKind.Identifier) {
91
- return expr.getText();
92
- }
93
- if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
94
- return expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
95
- }
96
- return null;
97
- }
98
-
99
- function findEnclosingName(call: CallExpression): string {
100
- let cur = call.getParent();
101
- while (cur) {
102
- if (cur.isKind(SyntaxKind.FunctionDeclaration) || cur.isKind(SyntaxKind.MethodDeclaration)) {
103
- return cur.getName() ?? "<anonymous>";
104
- }
105
- if (cur.isKind(SyntaxKind.FunctionExpression) || cur.isKind(SyntaxKind.ArrowFunction)) {
106
- const parent = cur.getParent();
107
- if (parent?.isKind(SyntaxKind.VariableDeclaration)) return parent.getName();
108
- if (parent?.isKind(SyntaxKind.PropertyAssignment)) return parent.getName();
109
- return "<anonymous>";
110
- }
111
- cur = cur.getParent();
112
- }
113
- return "<top-level>";
114
- }
115
-
116
- export const guard: AstGuard = {
117
- name: "loadAllEventsByType Guard",
118
- scan: SCAN,
119
- hint:
120
- "loadAllEventsByType buffers ALL events of an aggregate_type in memory (OOM cliff > ~100k events). " +
121
- "Use streamAllEventsByType (batched, memory-bounded) in production code. " +
122
- "Allowed only in tests, scripts/, and the event-store definition.",
123
- run(files) {
124
- const violations: GuardViolation[] = [];
125
- for (const sf of files) {
126
- if (EXCLUDE.test(sf.getFilePath())) continue;
127
- for (const v of collectViolations(sf).filter(isLocalFinding)) {
128
- violations.push({
129
- file: v.file,
130
- line: v.line,
131
- message: `${v.functionName}(...) in ${v.enclosingFunction}`,
132
- });
133
- }
134
- }
135
- return { violations };
136
- },
137
- };
138
-
139
- if (import.meta.main) runStandalone(guard);
@@ -1,119 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: `openToAll` access declarations need a real reason.
4
- *
5
- * The framework boot validator (access-declarations.ts) already rejects an
6
- * empty `reason`, and separately checks `openToAll.personalData` on
7
- * query/stream handlers and a PII write that is neither owner-bound nor
8
- * declares `personalData` — none of that is this
9
- * guard's job. This guard only catches what the boot validator can't: a
10
- * `reason` string that IS non-empty but is a placeholder (`"todo"`,
11
- * `"legacy"`, ...), plus the deprecated bare `openToAll: true` shape.
12
- *
13
- * Usage:
14
- * bun guards/guard-open-to-all-reason.ts
15
- * Baseline: kumiko-guards guards --write-security-baseline
16
- */
17
- import * as path from "node:path";
18
- import { type SourceFile, SyntaxKind } from "ts-morph";
19
- import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
20
- import {
21
- ALL_REPO_KINDS,
22
- type AstGuard,
23
- type GuardViolation,
24
- runStandalone,
25
- type ScanSpec,
26
- } from "./_lib/guard-kit";
27
-
28
- const SCAN: ScanSpec = {
29
- kinds: ALL_REPO_KINDS,
30
- scope: "source",
31
- extensions: ["ts"],
32
- frameworkWithin: ["packages/*/src/**", "samples/**"],
33
- };
34
- const EXCLUDE = /(__tests__\/|\.test\.tsx?$|\.d\.ts$)/;
35
-
36
- function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
37
- return files.filter((sf) => !EXCLUDE.test(sf.getFilePath()));
38
- }
39
-
40
- function openToAllPropertyAssignments(sf: SourceFile) {
41
- return sf
42
- .getDescendantsOfKind(SyntaxKind.PropertyAssignment)
43
- .filter((pa) => pa.getName() === "openToAll");
44
- }
45
-
46
- export function findDeprecatedOpenToAll(
47
- files: readonly SourceFile[],
48
- root: string,
49
- ): { file: string; line: number; message: string }[] {
50
- const out: { file: string; line: number; message: string }[] = [];
51
- for (const sf of scannableFiles(files)) {
52
- for (const pa of openToAllPropertyAssignments(sf)) {
53
- if (pa.getInitializer()?.getKind() !== SyntaxKind.TrueKeyword) continue;
54
- out.push({
55
- file: path.relative(root, sf.getFilePath()),
56
- line: pa.getStartLineNumber(),
57
- message:
58
- 'openToAll: true is deprecated — replace with openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data).',
59
- });
60
- }
61
- }
62
- return out;
63
- }
64
-
65
- export function findGenericOpenToAllReasons(
66
- files: readonly SourceFile[],
67
- root: string,
68
- ): { file: string; line: number; message: string }[] {
69
- const out: { file: string; line: number; message: string }[] = [];
70
- for (const sf of scannableFiles(files)) {
71
- for (const pa of openToAllPropertyAssignments(sf)) {
72
- const init = pa.getInitializer();
73
- if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
74
- const reasonProp = init.getProperty("reason");
75
- if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
76
- const reasonText = literalReasonText(reasonProp.getInitializer());
77
- if (reasonText === undefined) continue;
78
- if (reasonText.trim() === "") continue;
79
- if (!isGenericReason(reasonText)) continue;
80
- out.push({
81
- file: path.relative(root, sf.getFilePath()),
82
- line: pa.getStartLineNumber(),
83
- message: `openToAll: { reason: "${reasonText}" } uses a placeholder reason — give a concrete justification for why any authenticated user may call this.`,
84
- });
85
- }
86
- }
87
- return out;
88
- }
89
-
90
- export function createOpenToAllReasonGuard(opts: { root: string }): AstGuard {
91
- return {
92
- name: "Open-To-All-Reason Guard",
93
- scan: SCAN,
94
- security: true,
95
- hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
96
- run(files) {
97
- const violations: GuardViolation[] = [
98
- ...findGenericOpenToAllReasons(files, opts.root).map((f) => ({
99
- file: f.file,
100
- line: f.line,
101
- message: f.message,
102
- neverFrozen: true,
103
- })),
104
- ...findDeprecatedOpenToAll(files, opts.root).map((f) => ({
105
- file: f.file,
106
- line: f.line,
107
- message: f.message,
108
- })),
109
- ];
110
- return { violations };
111
- },
112
- };
113
- }
114
-
115
- export const guard = createOpenToAllReasonGuard({ root: process.cwd() });
116
-
117
- if (import.meta.main) {
118
- runStandalone(guard);
119
- }
@@ -1,206 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: finds pre-ES patterns that were removed with the event-sourcing
4
- * pivot and must not return.
5
- *
6
- * Checked patterns:
7
- *
8
- * 1. `createEventLog` / `EventLog` / `EventLogEntry`
9
- * The Redis-stream-based activity log was replaced by the `events`
10
- * table (postgres). A new import of this symbol is always a mistake —
11
- * the file no longer exists.
12
- *
13
- * 2. `ctx.emit` / `r.postEvent`
14
- * Before sprint E.2 these were the pubsub-event APIs. Replaced by
15
- * `ctx.appendEvent` (domain event on an aggregate stream) +
16
- * `r.multiStreamProjection` (async cross-aggregate consumers). The
17
- * symbols are gone; a use points at unmigrated code or an old
18
- * copy-paste from an orphaned sample.
19
- *
20
- * 3. `aggregateType: "configChanges"` as a string literal
21
- * Pre-ES stream from before the config→configValue refactor
22
- * (2026-04-24). Consumers now filter on
23
- * `configValue.created/updated/deleted`. A remaining string literal
24
- * would be a dead subscriber.
25
- *
26
- * 4. `CONFIG_CHANGED_EVENT_NAME` / `"config:event:config-changed"`
27
- * The pre-ES "config-changed" event was replaced by auto lifecycle
28
- * events.
29
- *
30
- * Exclude: markdown docs + this guard itself + commit messages. Comments in
31
- * TS code are deliberately scanned too — a "this is ok as a reference"
32
- * comment allowlist would open the door to revivals. Anyone who genuinely
33
- * needs to write about this in a comment uses backticks (`EventLog`) to
34
- * avoid the exact symbol, or a different spelling.
35
- *
36
- * Usage:
37
- * bun guards/guard-pre-es-patterns.ts (standalone)
38
- * via _lib/guard-kit shared runner (kumiko check)
39
- *
40
- * Exit 1 on violations, 0 when clean.
41
- */
42
-
43
- import * as fs from "node:fs";
44
- import * as path from "node:path";
45
- import { type SourceFile, SyntaxKind } from "ts-morph";
46
- import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
47
-
48
- const ROOT = process.cwd();
49
-
50
- const SCAN: ScanSpec = {
51
- scope: "source",
52
- extensions: ["ts"],
53
- frameworkWithin: ["packages/*/src/**", "samples/**"],
54
- };
55
-
56
- // Files that are NOT checked.
57
- // - dist/ — build output
58
- // - this guard itself (contains the pattern names in code)
59
- const EXCLUDE = /(^|\/)(dist|node_modules)\/|guard-pre-es-patterns\.ts$/;
60
-
61
- type Pattern = {
62
- readonly name: string;
63
- readonly description: string;
64
- readonly check: (sf: SourceFile) => Array<{ line: number; snippet: string }>;
65
- };
66
-
67
- // Identifier scan: checks whether a given name is used anywhere as an
68
- // identifier in the AST (not in strings, not in comments).
69
- function identifierHits(sf: SourceFile, name: string): Array<{ line: number; snippet: string }> {
70
- const out: Array<{ line: number; snippet: string }> = [];
71
- // Cheap text prefilter: a full descendant walk over every file costs seconds.
72
- if (!sf.getFullText().includes(name)) return out;
73
- for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
74
- if (id.getText() !== name) continue;
75
- out.push({
76
- line: id.getStartLineNumber(),
77
- snippet: id.getParent()?.getText().slice(0, 120) ?? name,
78
- });
79
- }
80
- return out;
81
- }
82
-
83
- // String-literal scan: checks whether a given string appears as a literal
84
- // value (e.g. in map lookups, SQL filters, event-match blocks).
85
- function stringLiteralHits(
86
- sf: SourceFile,
87
- literal: string,
88
- ): Array<{ line: number; snippet: string }> {
89
- const out: Array<{ line: number; snippet: string }> = [];
90
- if (!sf.getFullText().includes(literal)) return out;
91
- for (const lit of sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
92
- if (lit.getLiteralText() !== literal) continue;
93
- out.push({
94
- line: lit.getStartLineNumber(),
95
- snippet: lit.getParent()?.getText().slice(0, 120) ?? literal,
96
- });
97
- }
98
- for (const lit of sf.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
99
- if (lit.getLiteralText() !== literal) continue;
100
- out.push({
101
- line: lit.getStartLineNumber(),
102
- snippet: lit.getParent()?.getText().slice(0, 120) ?? literal,
103
- });
104
- }
105
- return out;
106
- }
107
-
108
- const PATTERNS: readonly Pattern[] = [
109
- {
110
- name: "createEventLog",
111
- description:
112
- "Pre-ES Redis-Stream-Activity-Log. Ersetzt durch die events-Tabelle + ctx.loadAggregate / queryProjection.",
113
- check: (sf) => identifierHits(sf, "createEventLog"),
114
- },
115
- {
116
- name: "EventLog / EventLogEntry (Type-Import)",
117
- description:
118
- "Type-Imports aus dem entfernten pipeline/event-log.ts. Ersetzt durch StoredEvent + getAllProjectionProgress.",
119
- check: (sf) => [...identifierHits(sf, "EventLog"), ...identifierHits(sf, "EventLogEntry")],
120
- },
121
- {
122
- name: "ctx.emit / emitEvent(ctx, …) pre-E.2",
123
- description:
124
- "Sprint-E.2 entfernte ctx.emit + PUBSUB_AGGREGATE_TYPE. Domain-Events gehen via ctx.appendEvent auf Aggregate-Streams.",
125
- check: (sf) => identifierHits(sf, "PUBSUB_AGGREGATE_TYPE"),
126
- },
127
- {
128
- name: "r.postEvent",
129
- description:
130
- "Sprint-E.2 entfernte r.postEvent als Registrar-API. Ersatz: r.multiStreamProjection für Cross-Aggregate-Konsumenten.",
131
- check: (sf) => {
132
- // Match `.postEvent(` property accesses to distinguish from the word
133
- // "postEvent" in prose. Identifier scan would miss method invocations.
134
- const out: Array<{ line: number; snippet: string }> = [];
135
- if (!sf.getFullText().includes("postEvent")) return out;
136
- for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
137
- if (pa.getName() !== "postEvent") continue;
138
- // Only flag when invoked as a method — a property read named
139
- // postEvent on foreign types (theoretical) isn't the registrar API.
140
- const parent = pa.getParent();
141
- if (parent?.getKind() !== SyntaxKind.CallExpression) continue;
142
- out.push({
143
- line: pa.getStartLineNumber(),
144
- snippet: pa.getParent()?.getText().slice(0, 120) ?? ".postEvent",
145
- });
146
- }
147
- return out;
148
- },
149
- },
150
- {
151
- name: 'aggregateType: "configChanges"',
152
- description:
153
- 'Pre-ES-Stream-Name. Konsumenten filtern auf aggregateType: "configValue" + Event-Types configValue.created/updated/deleted.',
154
- check: (sf) => stringLiteralHits(sf, "configChanges"),
155
- },
156
- {
157
- name: "config:event:config-changed Event-Name",
158
- description:
159
- "Pre-ES-Event. Ersetzt durch auto-Lifecycle-Events (configValue.created / .updated / .deleted).",
160
- check: (sf) => [
161
- ...stringLiteralHits(sf, "config:event:config-changed"),
162
- ...identifierHits(sf, "CONFIG_CHANGED_EVENT_NAME"),
163
- ],
164
- },
165
- ];
166
-
167
- export const guard: AstGuard = {
168
- name: "Pre-ES-Patterns Guard",
169
- scan: SCAN,
170
- hint:
171
- "These APIs were removed with the event-sourcing migration. Use the events table with ctx.loadAggregate / queryProjection, ctx.appendEvent on aggregate streams, " +
172
- 'r.multiStreamProjection for cross-aggregate consumers, and configValue.created/updated/deleted events (aggregateType: "configValue") for config changes.',
173
- run(files) {
174
- const violations: Array<{ file: string; line: number; message: string }> = [];
175
-
176
- for (const sf of files) {
177
- const file = sf.getFilePath();
178
- if (EXCLUDE.test(file)) continue;
179
-
180
- for (const pat of PATTERNS) {
181
- for (const hit of pat.check(sf)) {
182
- violations.push({
183
- file: path.relative(ROOT, file),
184
- line: hit.line,
185
- message: `[${pat.name}] ${hit.snippet}`,
186
- });
187
- }
188
- }
189
- }
190
-
191
- // Sanity assertion: pipeline/event-log.ts must not exist.
192
- const eventLogPath = path.join(ROOT, "packages/framework/src/pipeline/event-log.ts");
193
- if (fs.existsSync(eventLogPath)) {
194
- violations.push({
195
- file: "packages/framework/src/pipeline/event-log.ts",
196
- line: 0,
197
- message:
198
- "BLOCKED: pipeline/event-log.ts was restored. The file must be deleted — the events table now takes over its role.",
199
- });
200
- }
201
-
202
- return { violations };
203
- },
204
- };
205
-
206
- if (import.meta.main) runStandalone(guard);