@cosmicdrift/kumiko-guards 0.1.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.
- package/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- package/src/run-ui-guards.ts +25 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// web/ contains components and hooks — computation, parsing and aggregation
|
|
3
|
+
// belong in lib/ (with a test). This guard flags top-level functions in
|
|
4
|
+
// web/**/*.tsx that "compute" (control flow, local bindings, or a domain
|
|
5
|
+
// call in the return) but are NEITHER a component (JSX return) NOR a hook
|
|
6
|
+
// (use*) NOR a type guard (x is T). Pure single-return predicates stay
|
|
7
|
+
// allowed (typeof/comparisons without a call).
|
|
8
|
+
//
|
|
9
|
+
// Motivation: coverage thresholds don't see web/ (lib-only), and that's
|
|
10
|
+
// exactly where untested computation logic hides in 400-line screens. This
|
|
11
|
+
// guard is the structural counterpart — it makes logic-in-views visible and
|
|
12
|
+
// forces extraction into lib/.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type ArrowFunction,
|
|
16
|
+
type FunctionDeclaration,
|
|
17
|
+
type FunctionExpression,
|
|
18
|
+
Node,
|
|
19
|
+
type SourceFile,
|
|
20
|
+
SyntaxKind,
|
|
21
|
+
} from "ts-morph";
|
|
22
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
23
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
24
|
+
|
|
25
|
+
const SCAN: ScanSpec = {
|
|
26
|
+
scope: "source",
|
|
27
|
+
extensions: ["tsx"],
|
|
28
|
+
within: ["**/web/**"],
|
|
29
|
+
frameworkWithin: ["samples/apps/*/src/**/web/**"],
|
|
30
|
+
};
|
|
31
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
32
|
+
const IGNORE_TAG = "kumiko-lint-ignore no-logic-in-views";
|
|
33
|
+
|
|
34
|
+
type Callable = FunctionDeclaration | ArrowFunction | FunctionExpression;
|
|
35
|
+
|
|
36
|
+
function containsJsx(fn: Callable): boolean {
|
|
37
|
+
return (
|
|
38
|
+
fn.getDescendantsOfKind(SyntaxKind.JsxElement).length > 0 ||
|
|
39
|
+
fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0 ||
|
|
40
|
+
fn.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isHookName(name: string): boolean {
|
|
45
|
+
return /^use[A-Z0-9]/.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// React convention: PascalCase = component. In web/ these are render
|
|
49
|
+
// functions (including cell renderers that return a formatted string
|
|
50
|
+
// instead of a JSX element) — not an extraction candidate. camelCase
|
|
51
|
+
// functions are helpers/logic.
|
|
52
|
+
function isComponentName(name: string): boolean {
|
|
53
|
+
return /^[A-Z]/.test(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Type guard (x is T) or boolean return → predicate/guard, not an
|
|
57
|
+
// extraction candidate. The boolean case covers predicates that use
|
|
58
|
+
// primitive methods (d.name.trim().length > 0) without having to
|
|
59
|
+
// distinguish method calls from domain calls — the return semantics
|
|
60
|
+
// "decision" is the signal.
|
|
61
|
+
function isPredicate(fn: Callable): boolean {
|
|
62
|
+
const rt = fn.getReturnTypeNode();
|
|
63
|
+
if (rt?.getKind() === SyntaxKind.TypePredicate) return true;
|
|
64
|
+
if (rt?.getKind() === SyntaxKind.BooleanKeyword) return true;
|
|
65
|
+
// No explicit `: boolean` — check the inferred return type instead,
|
|
66
|
+
// otherwise unannotated predicates with a primitive method in the return
|
|
67
|
+
// (e.g. `function isReady(d) { return d.name.trim().length > 0 }`) fall
|
|
68
|
+
// through and get flagged as view logic incorrectly.
|
|
69
|
+
if (rt === undefined) return fn.getReturnType().isBoolean();
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A call in the return distinguishes the delegator/computation (summarize →
|
|
74
|
+
// summarizeScenario(...)) from the pure predicate (typeof x === "number" && …).
|
|
75
|
+
function hasCall(node: Node): boolean {
|
|
76
|
+
return (
|
|
77
|
+
node.getKind() === SyntaxKind.CallExpression ||
|
|
78
|
+
node.getDescendantsOfKind(SyntaxKind.CallExpression).length > 0
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Trivial = a single `return <expr>` without a call. Multiple statements,
|
|
83
|
+
// local bindings and control flow are per se not a single-return form and
|
|
84
|
+
// therefore automatically fall through (→ logic).
|
|
85
|
+
function isTrivialPredicate(fn: Callable): boolean {
|
|
86
|
+
const body = fn.getBody();
|
|
87
|
+
if (body === undefined) return true;
|
|
88
|
+
if (Node.isBlock(body)) {
|
|
89
|
+
const stmts = body.getStatements();
|
|
90
|
+
if (stmts.length !== 1) return false;
|
|
91
|
+
const only = stmts[0];
|
|
92
|
+
if (only === undefined || !Node.isReturnStatement(only)) return false;
|
|
93
|
+
const expr = only.getExpression();
|
|
94
|
+
return expr === undefined || !hasCall(expr);
|
|
95
|
+
}
|
|
96
|
+
// Expression-bodied arrow: const isX = (v) => v > 0
|
|
97
|
+
return !hasCall(body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isViewLogic(name: string, fn: Callable): boolean {
|
|
101
|
+
if (isHookName(name)) return false;
|
|
102
|
+
if (isComponentName(name) || containsJsx(fn)) return false;
|
|
103
|
+
if (isPredicate(fn)) return false;
|
|
104
|
+
return !isTrivialPredicate(fn);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function topLevelCallables(sf: SourceFile): { name: string; fn: Callable }[] {
|
|
108
|
+
const out: { name: string; fn: Callable }[] = [];
|
|
109
|
+
for (const fd of sf.getFunctions()) {
|
|
110
|
+
out.push({ name: fd.getName() ?? "", fn: fd });
|
|
111
|
+
}
|
|
112
|
+
for (const vs of sf.getVariableStatements()) {
|
|
113
|
+
for (const decl of vs.getDeclarations()) {
|
|
114
|
+
const init = decl.getInitializer();
|
|
115
|
+
if (init !== undefined && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
|
|
116
|
+
out.push({ name: decl.getName(), fn: init });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const guard: AstGuard = {
|
|
124
|
+
name: "No-Logic-in-Views Guard (App-Repos)",
|
|
125
|
+
scan: SCAN,
|
|
126
|
+
hint:
|
|
127
|
+
"Berechnung/Parsing/Aggregation gehört nach lib/ (mit Test) — web/ enthält nur " +
|
|
128
|
+
`Komponenten und Hooks. Begründete Ausnahme: // ${IGNORE_TAG} <Grund>`,
|
|
129
|
+
run(files: readonly SourceFile[]) {
|
|
130
|
+
const violations: GuardViolation[] = [];
|
|
131
|
+
for (const sf of files) {
|
|
132
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
133
|
+
for (const { name, fn } of topLevelCallables(sf)) {
|
|
134
|
+
if (name === "" || !isViewLogic(name, fn)) continue;
|
|
135
|
+
if (hasIgnoreTag(fn, IGNORE_TAG)) continue;
|
|
136
|
+
violations.push({
|
|
137
|
+
file: sf.getFilePath(),
|
|
138
|
+
line: fn.getStartLineNumber(),
|
|
139
|
+
message: `View-Logik "${name}" gehört nach lib/ (mit Test) — web/ nur Komponenten/Hooks`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { violations };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Rerender protection: raw useEffect/useLayoutEffect + fetch() in app
|
|
3
|
+
// screens are the source of the endless-rerender class (unstable deps,
|
|
4
|
+
// setState-in-effect) and bypass the framework hook set (useQuery/
|
|
5
|
+
// useMutation/useDisclosure — useQuery can go live via SSE). Endless
|
|
6
|
+
// rerenders aren't reliably detectable statically; the guard eliminates the
|
|
7
|
+
// pattern that produces them.
|
|
8
|
+
//
|
|
9
|
+
// Local UI state via useState stays allowed.
|
|
10
|
+
//
|
|
11
|
+
// Part of App-Mounting 2.0 (infra#208).
|
|
12
|
+
|
|
13
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
14
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
15
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
16
|
+
|
|
17
|
+
// Match property/identifier names instead of call text — otherwise
|
|
18
|
+
// namespace-/alias-qualified calls (`React.useEffect(...)`, a renamed
|
|
19
|
+
// import `useEffect as useFx`) slip through, because getText() returns the
|
|
20
|
+
// full expression ("React.useEffect") instead of just the banned name.
|
|
21
|
+
function calleeName(expr: import("ts-morph").Node): string {
|
|
22
|
+
if (Node.isPropertyAccessExpression(expr)) return expr.getName();
|
|
23
|
+
return expr.getText();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Only component files in web/public code. API clients (*.ts) stay out of
|
|
27
|
+
// scope — they encapsulate fetch deliberately in one place.
|
|
28
|
+
const SCAN: ScanSpec = {
|
|
29
|
+
scope: "source",
|
|
30
|
+
extensions: ["tsx"],
|
|
31
|
+
within: ["**/web/**", "public/**"],
|
|
32
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
33
|
+
};
|
|
34
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
35
|
+
const IGNORE_TAG = "kumiko-lint-ignore no-raw-hooks";
|
|
36
|
+
|
|
37
|
+
const BANNED_HOOKS = new Set(["useEffect", "useLayoutEffect"]);
|
|
38
|
+
|
|
39
|
+
export const guard: AstGuard = {
|
|
40
|
+
name: "No-Raw-Hooks Guard (App-Repos)",
|
|
41
|
+
scan: SCAN,
|
|
42
|
+
hint:
|
|
43
|
+
"Framework-Hook-Satz nutzen: useQuery (live: true für SSE), useMutation, useDisclosure. " +
|
|
44
|
+
`Echter Sonderfall (DOM-Integration o.ä.): // ${IGNORE_TAG} <Grund>`,
|
|
45
|
+
run(files: readonly SourceFile[]) {
|
|
46
|
+
const violations: GuardViolation[] = [];
|
|
47
|
+
for (const sf of files) {
|
|
48
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
49
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
50
|
+
const callee = call.getExpression();
|
|
51
|
+
const name = calleeName(callee);
|
|
52
|
+
if (BANNED_HOOKS.has(name)) {
|
|
53
|
+
if (hasIgnoreTag(call, IGNORE_TAG)) continue;
|
|
54
|
+
violations.push({
|
|
55
|
+
file: sf.getFilePath(),
|
|
56
|
+
line: call.getStartLineNumber(),
|
|
57
|
+
message: `${name} in App-Screen — Framework-Hooks nutzen (useQuery/useMutation/useDisclosure)`,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (name === "fetch") {
|
|
62
|
+
if (hasIgnoreTag(call, IGNORE_TAG)) continue;
|
|
63
|
+
violations.push({
|
|
64
|
+
file: sf.getFilePath(),
|
|
65
|
+
line: call.getStartLineNumber(),
|
|
66
|
+
message:
|
|
67
|
+
"fetch() in App-Screen — useQuery/useMutation bzw. einen Api-Client (*.ts) nutzen",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { violations };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,112 @@
|
|
|
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: bun guards/run-guards.ts --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 { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
21
|
+
|
|
22
|
+
const SCAN: ScanSpec = {
|
|
23
|
+
scope: "source",
|
|
24
|
+
extensions: ["ts"],
|
|
25
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
26
|
+
};
|
|
27
|
+
const EXCLUDE = /(__tests__\/|\.test\.tsx?$|\.d\.ts$)/;
|
|
28
|
+
|
|
29
|
+
function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
|
|
30
|
+
return files.filter((sf) => !EXCLUDE.test(sf.getFilePath()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function openToAllPropertyAssignments(sf: SourceFile) {
|
|
34
|
+
return sf
|
|
35
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
36
|
+
.filter((pa) => pa.getName() === "openToAll");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findDeprecatedOpenToAll(
|
|
40
|
+
files: readonly SourceFile[],
|
|
41
|
+
root: string,
|
|
42
|
+
): { file: string; line: number; message: string }[] {
|
|
43
|
+
const out: { file: string; line: number; message: string }[] = [];
|
|
44
|
+
for (const sf of scannableFiles(files)) {
|
|
45
|
+
for (const pa of openToAllPropertyAssignments(sf)) {
|
|
46
|
+
if (pa.getInitializer()?.getKind() !== SyntaxKind.TrueKeyword) continue;
|
|
47
|
+
out.push({
|
|
48
|
+
file: path.relative(root, sf.getFilePath()),
|
|
49
|
+
line: pa.getStartLineNumber(),
|
|
50
|
+
message:
|
|
51
|
+
'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).',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findGenericOpenToAllReasons(
|
|
59
|
+
files: readonly SourceFile[],
|
|
60
|
+
root: string,
|
|
61
|
+
): { file: string; line: number; message: string }[] {
|
|
62
|
+
const out: { file: string; line: number; message: string }[] = [];
|
|
63
|
+
for (const sf of scannableFiles(files)) {
|
|
64
|
+
for (const pa of openToAllPropertyAssignments(sf)) {
|
|
65
|
+
const init = pa.getInitializer();
|
|
66
|
+
if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
67
|
+
const reasonProp = init.getProperty("reason");
|
|
68
|
+
if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
69
|
+
const reasonText = literalReasonText(reasonProp.getInitializer());
|
|
70
|
+
if (reasonText === undefined) continue;
|
|
71
|
+
if (reasonText.trim() === "") continue;
|
|
72
|
+
if (!isGenericReason(reasonText)) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
file: path.relative(root, sf.getFilePath()),
|
|
75
|
+
line: pa.getStartLineNumber(),
|
|
76
|
+
message: `openToAll: { reason: "${reasonText}" } uses a placeholder reason — give a concrete justification for why any authenticated user may call this.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createOpenToAllReasonGuard(opts: { root: string }): AstGuard {
|
|
84
|
+
return {
|
|
85
|
+
name: "Open-To-All-Reason Guard",
|
|
86
|
+
scan: SCAN,
|
|
87
|
+
security: true,
|
|
88
|
+
hint: 'openToAll: { reason: "<why any authenticated user may call this>" } angeben (+ personalData: "tenant-members" bei Write-Handlern mit nicht gebundenen Personendaten). Baseline nach bewusster Reduktion: `bun guards/run-guards.ts --write-security-baseline`',
|
|
89
|
+
run(files) {
|
|
90
|
+
const violations: GuardViolation[] = [
|
|
91
|
+
...findGenericOpenToAllReasons(files, opts.root).map((f) => ({
|
|
92
|
+
file: f.file,
|
|
93
|
+
line: f.line,
|
|
94
|
+
message: f.message,
|
|
95
|
+
neverFrozen: true,
|
|
96
|
+
})),
|
|
97
|
+
...findDeprecatedOpenToAll(files, opts.root).map((f) => ({
|
|
98
|
+
file: f.file,
|
|
99
|
+
line: f.line,
|
|
100
|
+
message: f.message,
|
|
101
|
+
})),
|
|
102
|
+
];
|
|
103
|
+
return { violations };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const guard = createOpenToAllReasonGuard({ root: process.cwd() });
|
|
109
|
+
|
|
110
|
+
if (import.meta.main) {
|
|
111
|
+
runStandalone(guard);
|
|
112
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
72
|
+
if (id.getText() !== name) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
line: id.getStartLineNumber(),
|
|
75
|
+
snippet: id.getParent()?.getText().slice(0, 120) ?? name,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// String-literal scan: checks whether a given string appears as a literal
|
|
82
|
+
// value (e.g. in map lookups, SQL filters, event-match blocks).
|
|
83
|
+
function stringLiteralHits(
|
|
84
|
+
sf: SourceFile,
|
|
85
|
+
literal: string,
|
|
86
|
+
): Array<{ line: number; snippet: string }> {
|
|
87
|
+
const out: Array<{ line: number; snippet: string }> = [];
|
|
88
|
+
for (const lit of sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
|
89
|
+
if (lit.getLiteralText() !== literal) continue;
|
|
90
|
+
out.push({
|
|
91
|
+
line: lit.getStartLineNumber(),
|
|
92
|
+
snippet: lit.getParent()?.getText().slice(0, 120) ?? literal,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const lit of sf.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
96
|
+
if (lit.getLiteralText() !== literal) continue;
|
|
97
|
+
out.push({
|
|
98
|
+
line: lit.getStartLineNumber(),
|
|
99
|
+
snippet: lit.getParent()?.getText().slice(0, 120) ?? literal,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PATTERNS: readonly Pattern[] = [
|
|
106
|
+
{
|
|
107
|
+
name: "createEventLog",
|
|
108
|
+
description:
|
|
109
|
+
"Pre-ES Redis-Stream-Activity-Log. Ersetzt durch die events-Tabelle + ctx.loadAggregate / queryProjection.",
|
|
110
|
+
check: (sf) => identifierHits(sf, "createEventLog"),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "EventLog / EventLogEntry (Type-Import)",
|
|
114
|
+
description:
|
|
115
|
+
"Type-Imports aus dem entfernten pipeline/event-log.ts. Ersetzt durch StoredEvent + getAllProjectionProgress.",
|
|
116
|
+
check: (sf) => [...identifierHits(sf, "EventLog"), ...identifierHits(sf, "EventLogEntry")],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "ctx.emit / emitEvent(ctx, …) pre-E.2",
|
|
120
|
+
description:
|
|
121
|
+
"Sprint-E.2 entfernte ctx.emit + PUBSUB_AGGREGATE_TYPE. Domain-Events gehen via ctx.appendEvent auf Aggregate-Streams.",
|
|
122
|
+
check: (sf) => identifierHits(sf, "PUBSUB_AGGREGATE_TYPE"),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "r.postEvent",
|
|
126
|
+
description:
|
|
127
|
+
"Sprint-E.2 entfernte r.postEvent als Registrar-API. Ersatz: r.multiStreamProjection für Cross-Aggregate-Konsumenten.",
|
|
128
|
+
check: (sf) => {
|
|
129
|
+
// Match `.postEvent(` property accesses to distinguish from the word
|
|
130
|
+
// "postEvent" in prose. Identifier scan would miss method invocations.
|
|
131
|
+
const out: Array<{ line: number; snippet: string }> = [];
|
|
132
|
+
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
133
|
+
if (pa.getName() !== "postEvent") continue;
|
|
134
|
+
// Only flag when invoked as a method — a property read named
|
|
135
|
+
// postEvent on foreign types (theoretical) isn't the registrar API.
|
|
136
|
+
const parent = pa.getParent();
|
|
137
|
+
if (parent?.getKind() !== SyntaxKind.CallExpression) continue;
|
|
138
|
+
out.push({
|
|
139
|
+
line: pa.getStartLineNumber(),
|
|
140
|
+
snippet: pa.getParent()?.getText().slice(0, 120) ?? ".postEvent",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'aggregateType: "configChanges"',
|
|
148
|
+
description:
|
|
149
|
+
'Pre-ES-Stream-Name. Konsumenten filtern auf aggregateType: "configValue" + Event-Types configValue.created/updated/deleted.',
|
|
150
|
+
check: (sf) => stringLiteralHits(sf, "configChanges"),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "config:event:config-changed Event-Name",
|
|
154
|
+
description:
|
|
155
|
+
"Pre-ES-Event. Ersetzt durch auto-Lifecycle-Events (configValue.created / .updated / .deleted).",
|
|
156
|
+
check: (sf) => [
|
|
157
|
+
...stringLiteralHits(sf, "config:event:config-changed"),
|
|
158
|
+
...identifierHits(sf, "CONFIG_CHANGED_EVENT_NAME"),
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
export const guard: AstGuard = {
|
|
164
|
+
name: "Pre-ES-Patterns Guard",
|
|
165
|
+
scan: SCAN,
|
|
166
|
+
run(files) {
|
|
167
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
168
|
+
|
|
169
|
+
for (const sf of files) {
|
|
170
|
+
const file = sf.getFilePath();
|
|
171
|
+
if (EXCLUDE.test(file)) continue;
|
|
172
|
+
|
|
173
|
+
for (const pat of PATTERNS) {
|
|
174
|
+
for (const hit of pat.check(sf)) {
|
|
175
|
+
violations.push({
|
|
176
|
+
file: path.relative(ROOT, file),
|
|
177
|
+
line: hit.line,
|
|
178
|
+
message: `[${pat.name}] ${hit.snippet}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Sanity assertion: pipeline/event-log.ts must not exist.
|
|
185
|
+
const eventLogPath = path.join(ROOT, "packages/framework/src/pipeline/event-log.ts");
|
|
186
|
+
if (fs.existsSync(eventLogPath)) {
|
|
187
|
+
violations.push({
|
|
188
|
+
file: "packages/framework/src/pipeline/event-log.ts",
|
|
189
|
+
line: 0,
|
|
190
|
+
message:
|
|
191
|
+
"BLOCKED: pipeline/event-log.ts wurde wiederhergestellt. Die Datei gehört gelöscht — ihre Rolle übernimmt die events-Tabelle.",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { violations };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (import.meta.main) runStandalone(guard);
|