@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,157 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: prevents `@cosmicdrift/kumiko-renderer` (shared, platform-neutral)
|
|
4
|
+
* from touching DOM- or platform-specific APIs. As soon as window/document/
|
|
5
|
+
* EventSource/react-dom show up, the separation is broken and the code
|
|
6
|
+
* belongs in `@cosmicdrift/kumiko-renderer-web` (or renderer-native) instead.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately simple: a regex scan over the source text. False positives
|
|
9
|
+
* (e.g. "window" in a string literal) are possible but rare — the guard is a
|
|
10
|
+
* maintenance alarm, not a semantic proof.
|
|
11
|
+
*
|
|
12
|
+
* Checked:
|
|
13
|
+
* - Imports: react-dom/*, jsdom, @cosmicdrift/kumiko-renderer-web, @cosmicdrift/kumiko-renderer-native
|
|
14
|
+
* - Symbols: window., document., location., history., localStorage,
|
|
15
|
+
* sessionStorage, navigator., EventSource, fetch
|
|
16
|
+
* (bare — unqualified)
|
|
17
|
+
*
|
|
18
|
+
* __tests__ folders are excluded — tests mount in jsdom, that's expected.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
24
|
+
|
|
25
|
+
// Imports that are taboo in the shared layer.
|
|
26
|
+
const FORBIDDEN_IMPORTS = [
|
|
27
|
+
/from ["']react-dom(?:\/.*)?["']/,
|
|
28
|
+
/from ["']jsdom["']/,
|
|
29
|
+
/from ["']@cosmicdrift\/kumiko-renderer-web["']/,
|
|
30
|
+
/from ["']@cosmicdrift\/kumiko-renderer-native["']/,
|
|
31
|
+
/from ["']@cosmicdrift\/kumiko-dispatcher-live["']/,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Runtime symbols that mean DOM/browser. `\b` prevents substring matches
|
|
35
|
+
// (e.g. "origin" not matched as "origin.foo"). This is a coarse guard — a
|
|
36
|
+
// ts-morph-based check would be more precise, but not worth the effort here.
|
|
37
|
+
const FORBIDDEN_SYMBOLS = [
|
|
38
|
+
/\bwindow\s*\./,
|
|
39
|
+
/\bdocument\s*\./,
|
|
40
|
+
/\blocation\s*\./,
|
|
41
|
+
/\bhistory\s*\./,
|
|
42
|
+
/\blocalStorage\b/,
|
|
43
|
+
/\bsessionStorage\b/,
|
|
44
|
+
/\bnavigator\s*\./,
|
|
45
|
+
/\bEventSource\b/,
|
|
46
|
+
/\bHTMLElement\b/,
|
|
47
|
+
/\bcreateRoot\b/,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// JSX elements with a lowercase name = HTML tags (React convention:
|
|
51
|
+
// lowercase = intrinsic DOM element, Capitalized = component). The shared
|
|
52
|
+
// renderer must not emit DOM tags — everything goes through primitives,
|
|
53
|
+
// which are platform-bound.
|
|
54
|
+
//
|
|
55
|
+
// Match: `<tagname` at the start of a JSX opening, with a lowercase letter.
|
|
56
|
+
// Ignored: fragment `<>`, components `<Capitalized`, attribute values like
|
|
57
|
+
// `<string>` in a type-annotation context (the regex only matches right
|
|
58
|
+
// after whitespace/newline/>, not after an identifier character).
|
|
59
|
+
const FORBIDDEN_JSX_TAG = /(^|\s|>|\()<([a-z][a-zA-Z0-9-]*)[\s/>]/;
|
|
60
|
+
|
|
61
|
+
type Violation = {
|
|
62
|
+
readonly file: string;
|
|
63
|
+
readonly line: number;
|
|
64
|
+
readonly rule: string;
|
|
65
|
+
readonly excerpt: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function walk(dir: string): string[] {
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
const full = path.join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (entry.name === "__tests__") continue;
|
|
74
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
75
|
+
out.push(...walk(full));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
|
|
79
|
+
out.push(full);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function findViolations(file: string, root: string): Violation[] {
|
|
86
|
+
const text = fs.readFileSync(file, "utf-8");
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
const violations: Violation[] = [];
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const line = lines[i] ?? "";
|
|
91
|
+
// Skip comments. Pure heuristic — multi-line block comments aren't
|
|
92
|
+
// caught. Good enough for source that doesn't jsdoc-block-paste 'window.*' as prose.
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
95
|
+
|
|
96
|
+
for (const pattern of FORBIDDEN_IMPORTS) {
|
|
97
|
+
if (pattern.test(line)) {
|
|
98
|
+
violations.push({
|
|
99
|
+
file: path.relative(root, file),
|
|
100
|
+
line: i + 1,
|
|
101
|
+
rule: "forbidden-import",
|
|
102
|
+
excerpt: trimmed,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const pattern of FORBIDDEN_SYMBOLS) {
|
|
107
|
+
if (pattern.test(line)) {
|
|
108
|
+
violations.push({
|
|
109
|
+
file: path.relative(root, file),
|
|
110
|
+
line: i + 1,
|
|
111
|
+
rule: "forbidden-symbol",
|
|
112
|
+
excerpt: trimmed,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const jsxMatch = line.match(FORBIDDEN_JSX_TAG);
|
|
117
|
+
if (jsxMatch !== null) {
|
|
118
|
+
violations.push({
|
|
119
|
+
file: path.relative(root, file),
|
|
120
|
+
line: i + 1,
|
|
121
|
+
rule: `forbidden-jsx-tag (<${jsxMatch[2]}>)`,
|
|
122
|
+
excerpt: trimmed,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return violations;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const check: RepoCheck = {
|
|
130
|
+
name: "Renderer-Boundaries Guard",
|
|
131
|
+
hint: "@cosmicdrift/kumiko-renderer darf keine DOM-/Browser-/Platform-APIs nutzen. Platform-spezifischer Code gehört nach @cosmicdrift/kumiko-renderer-web (oder renderer-native).",
|
|
132
|
+
run(roots) {
|
|
133
|
+
const frameworkRoots = roots.filter((r) => r.kind === "framework");
|
|
134
|
+
const scanDirs = frameworkRoots
|
|
135
|
+
.map((r) => path.join(r.absPath, "packages/renderer/src"))
|
|
136
|
+
.filter((p) => fs.existsSync(p));
|
|
137
|
+
if (scanDirs.length === 0) {
|
|
138
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
139
|
+
}
|
|
140
|
+
const files: string[] = [];
|
|
141
|
+
for (const dir of scanDirs) files.push(...walk(dir));
|
|
142
|
+
const root = frameworkRoots[0]?.absPath ?? process.cwd();
|
|
143
|
+
const violations = files.flatMap((file) =>
|
|
144
|
+
findViolations(file, root).map((v) => ({
|
|
145
|
+
file: v.file,
|
|
146
|
+
line: v.line,
|
|
147
|
+
message: `[${v.rule}] ${v.excerpt}`,
|
|
148
|
+
})),
|
|
149
|
+
);
|
|
150
|
+
return { violations, matchedFiles: files.length, notApplicable: false };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (import.meta.main) {
|
|
155
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
156
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
157
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: finds imports/re-exports of the unscoped stream primitives outside
|
|
4
|
+
* an allowlist.
|
|
5
|
+
*
|
|
6
|
+
* `getUnscopedAggregateStreamMaxVersion` / `getUnscopedAggregateStreamTenant`
|
|
7
|
+
* (packages/framework/src/event-store/event-store.ts) have no tenant
|
|
8
|
+
* filter — they are an existence oracle for foreign tenants (see
|
|
9
|
+
* kumiko-framework#1269). Legitimate only for seed-/system-internal code
|
|
10
|
+
* that deliberately works across tenants.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* bun guards/guard-restricted-symbols.ts
|
|
14
|
+
*
|
|
15
|
+
* Exit 1 on violations in non-allowlisted files, 0 when clean.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
19
|
+
import {
|
|
20
|
+
type AstGuard,
|
|
21
|
+
isAllowlisted,
|
|
22
|
+
relFromRepoRoot,
|
|
23
|
+
runStandalone,
|
|
24
|
+
type ScanSpec,
|
|
25
|
+
} from "./_lib/guard-kit";
|
|
26
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
27
|
+
|
|
28
|
+
const SCAN: ScanSpec = {
|
|
29
|
+
scope: "source",
|
|
30
|
+
extensions: ["ts"],
|
|
31
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
35
|
+
|
|
36
|
+
const RESTRICTED_SYMBOLS = new Set([
|
|
37
|
+
"getUnscopedAggregateStreamMaxVersion",
|
|
38
|
+
"getUnscopedAggregateStreamTenant",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const ALLOWLIST = [
|
|
42
|
+
/^packages\/framework\/src\/event-store\/event-store\.ts$/,
|
|
43
|
+
/^packages\/framework\/src\/event-store\/index\.ts$/,
|
|
44
|
+
/^packages\/bundled-features\/src\/tenant\/seeding\.ts$/,
|
|
45
|
+
/^packages\/bundled-features\/src\/tier-engine\/feature\.ts$/,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
interface Violation {
|
|
49
|
+
line: number;
|
|
50
|
+
symbol: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Namespace import (`import * as es from "...event-store"; es.getUnscoped...()`)
|
|
54
|
+
// doesn't bind a named import declaration, so it slips past
|
|
55
|
+
// findRestrictedSymbolReferences() below — catch `<namespace>.<restrictedSymbol>`
|
|
56
|
+
// property access instead. `export *` re-exports remain an unaddressed gap
|
|
57
|
+
// (see the guard's `hint` for the known limitation).
|
|
58
|
+
function findNamespacedSymbolAccesses(sf: SourceFile): Violation[] {
|
|
59
|
+
const violations: Violation[] = [];
|
|
60
|
+
|
|
61
|
+
const namespaceNames = new Set(
|
|
62
|
+
sf
|
|
63
|
+
.getImportDeclarations()
|
|
64
|
+
.map((d) => d.getNamespaceImport())
|
|
65
|
+
.filter((ns): ns is NonNullable<typeof ns> => ns !== undefined)
|
|
66
|
+
.map((ns) => ns.getText()),
|
|
67
|
+
);
|
|
68
|
+
if (namespaceNames.size === 0) return violations;
|
|
69
|
+
|
|
70
|
+
for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
71
|
+
const objText = access.getExpression().getText();
|
|
72
|
+
const propName = access.getNameNode().getText();
|
|
73
|
+
if (namespaceNames.has(objText) && RESTRICTED_SYMBOLS.has(propName)) {
|
|
74
|
+
violations.push({ line: access.getStartLineNumber(), symbol: propName });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return violations;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Named imports (`import { X } from ...`) and re-exports (`export { X } from
|
|
82
|
+
// ...`) both cross a module boundary — either shape counts as an unguarded
|
|
83
|
+
// reference. getName() returns the imported/exported symbol's original name,
|
|
84
|
+
// not a local alias, so `as`-renamed references are still caught.
|
|
85
|
+
function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
|
|
86
|
+
const violations: Violation[] = [];
|
|
87
|
+
|
|
88
|
+
for (const importDecl of sf.getImportDeclarations()) {
|
|
89
|
+
for (const named of importDecl.getNamedImports()) {
|
|
90
|
+
const name = named.getName();
|
|
91
|
+
if (RESTRICTED_SYMBOLS.has(name)) {
|
|
92
|
+
violations.push({ line: named.getStartLineNumber(), symbol: name });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
98
|
+
if (!exportDecl.getModuleSpecifier()) continue;
|
|
99
|
+
for (const named of exportDecl.getNamedExports()) {
|
|
100
|
+
const name = named.getName();
|
|
101
|
+
if (RESTRICTED_SYMBOLS.has(name)) {
|
|
102
|
+
violations.push({ line: named.getStartLineNumber(), symbol: name });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
violations.push(...findNamespacedSymbolAccesses(sf));
|
|
108
|
+
|
|
109
|
+
return violations;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const guard: AstGuard = {
|
|
113
|
+
name: "Restricted-Symbols Guard",
|
|
114
|
+
scan: SCAN,
|
|
115
|
+
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} sind ein Existenz-Orakel für fremde Tenants — nur Seed-/System-interner Code darf sie referenzieren. Neuer Caller nötig? Allowlist in guard-restricted-symbols.ts erweitern, mit Begründung. Bekannte Lücke: `export * from "...event-store"` wird nicht erkannt (Named-Imports, Re-Exports und Namespace-Property-Access sind abgedeckt).',
|
|
116
|
+
run(files) {
|
|
117
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
118
|
+
const roots = resolveRepoRoots();
|
|
119
|
+
|
|
120
|
+
for (const sf of files) {
|
|
121
|
+
const file = sf.getFilePath();
|
|
122
|
+
const rel = relFromRepoRoot(file, roots);
|
|
123
|
+
if (EXCLUDE.test(rel)) continue;
|
|
124
|
+
if (isAllowlisted(rel, ALLOWLIST)) continue;
|
|
125
|
+
for (const v of findRestrictedSymbolReferences(sf)) {
|
|
126
|
+
violations.push({
|
|
127
|
+
file: rel,
|
|
128
|
+
line: v.line,
|
|
129
|
+
message: `[${v.symbol}] unscoped stream-primitive referenced outside allowlist`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { violations };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: finds "silent skip" spots in production code.
|
|
4
|
+
*
|
|
5
|
+
* A "silent skip" is a bare `return;` (no value) in code that skips logic
|
|
6
|
+
* unnoticed — typical in hooks, handlers, middleware.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun guards/guard-silent-skip.ts
|
|
10
|
+
*
|
|
11
|
+
* Exit 1 on violations, 0 when clean.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { type Node, type ReturnStatement, type SourceFile, SyntaxKind } from "ts-morph";
|
|
16
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
17
|
+
|
|
18
|
+
const ROOT = process.cwd();
|
|
19
|
+
|
|
20
|
+
const SCAN: ScanSpec = {
|
|
21
|
+
scope: "source",
|
|
22
|
+
extensions: ["ts"],
|
|
23
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
27
|
+
|
|
28
|
+
export interface SkipSite {
|
|
29
|
+
file: string;
|
|
30
|
+
line: number;
|
|
31
|
+
enclosingFunction: string;
|
|
32
|
+
enclosingKind: "hook" | "handler" | "middleware" | "function" | "arrow" | "method";
|
|
33
|
+
precedingText: string;
|
|
34
|
+
commentsAbove?: string;
|
|
35
|
+
snippet: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the context for a bare return. Pure data extraction — no policy.
|
|
40
|
+
* The allow/deny decision happens in isAllowed().
|
|
41
|
+
*/
|
|
42
|
+
function describeSite(ret: ReturnStatement, sourceFile: SourceFile): SkipSite {
|
|
43
|
+
const enclosing = findEnclosingFunction(ret);
|
|
44
|
+
const fnName = enclosing.name;
|
|
45
|
+
const kind = classifyEnclosing(ret, enclosing.node);
|
|
46
|
+
|
|
47
|
+
const prev = ret.getPreviousSibling();
|
|
48
|
+
const precedingText = prev?.getText() ?? "";
|
|
49
|
+
|
|
50
|
+
// Collect comments from multiple possible locations:
|
|
51
|
+
// 1. Leading trivia on the return itself (`// skip:` on the line above a standalone return)
|
|
52
|
+
// 2. Leading trivia on the enclosing IfStatement (`// skip:` before `if (x) return;`)
|
|
53
|
+
// 3. Trailing comment on the previous sibling statement
|
|
54
|
+
const comments: string[] = [];
|
|
55
|
+
comments.push(...ret.getLeadingCommentRanges().map((r) => r.getText()));
|
|
56
|
+
const enclosingIf = ret.getFirstAncestorByKind(SyntaxKind.IfStatement);
|
|
57
|
+
if (enclosingIf && enclosingIf.getStartLineNumber() >= ret.getStartLineNumber() - 2) {
|
|
58
|
+
comments.push(...enclosingIf.getLeadingCommentRanges().map((r) => r.getText()));
|
|
59
|
+
}
|
|
60
|
+
if (prev?.getTrailingCommentRanges) {
|
|
61
|
+
comments.push(...prev.getTrailingCommentRanges().map((r) => r.getText()));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
file: path.relative(ROOT, sourceFile.getFilePath()),
|
|
66
|
+
line: ret.getStartLineNumber(),
|
|
67
|
+
enclosingFunction: fnName,
|
|
68
|
+
enclosingKind: kind,
|
|
69
|
+
precedingText,
|
|
70
|
+
commentsAbove: comments.join("\n"),
|
|
71
|
+
snippet: ret.getText(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findEnclosingFunction(node: Node): { node: Node; name: string } {
|
|
76
|
+
let cur: Node | undefined = node.getParent();
|
|
77
|
+
while (cur) {
|
|
78
|
+
if (
|
|
79
|
+
cur.isKind(SyntaxKind.FunctionDeclaration) ||
|
|
80
|
+
cur.isKind(SyntaxKind.FunctionExpression) ||
|
|
81
|
+
cur.isKind(SyntaxKind.ArrowFunction) ||
|
|
82
|
+
cur.isKind(SyntaxKind.MethodDeclaration)
|
|
83
|
+
) {
|
|
84
|
+
return { node: cur, name: guessName(cur) };
|
|
85
|
+
}
|
|
86
|
+
cur = cur.getParent();
|
|
87
|
+
}
|
|
88
|
+
return { node, name: "<top-level>" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function guessName(fn: Node): string {
|
|
92
|
+
if (fn.isKind(SyntaxKind.FunctionDeclaration) || fn.isKind(SyntaxKind.MethodDeclaration)) {
|
|
93
|
+
return fn.getName() ?? "<anonymous>";
|
|
94
|
+
}
|
|
95
|
+
const parent = fn.getParent();
|
|
96
|
+
if (parent?.isKind(SyntaxKind.VariableDeclaration)) return parent.getName();
|
|
97
|
+
if (parent?.isKind(SyntaxKind.PropertyAssignment)) return parent.getName();
|
|
98
|
+
const callExpr = parent?.isKind(SyntaxKind.CallExpression) ? parent : undefined;
|
|
99
|
+
if (callExpr) return `${callExpr.getExpression().getText()}(...)`;
|
|
100
|
+
return "<anonymous>";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function classifyEnclosing(_ret: ReturnStatement, fn: Node): SkipSite["enclosingKind"] {
|
|
104
|
+
const text = fn.getParent()?.getText() ?? "";
|
|
105
|
+
if (/\bhook\s*\(|postSave|preSave|validation/.test(text)) return "hook";
|
|
106
|
+
if (/Handler\b|writeHandler|queryHandler/.test(text)) return "handler";
|
|
107
|
+
if (/middleware|Middleware/.test(text)) return "middleware";
|
|
108
|
+
if (fn.isKind(SyntaxKind.MethodDeclaration)) return "method";
|
|
109
|
+
if (fn.isKind(SyntaxKind.ArrowFunction)) return "arrow";
|
|
110
|
+
return "function";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// POLICY
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Policy: a bare `return;` needs one of the following markers right before it
|
|
119
|
+
* — otherwise it is flagged.
|
|
120
|
+
*
|
|
121
|
+
* (a) Log call: ctx.log?.debug(...), logger.debug(...), context.log?.info(...)
|
|
122
|
+
* (b) Skip comment: // skip: <reason>
|
|
123
|
+
* (c) Throw escape: preceded by a throw (the return is dead code, should go)
|
|
124
|
+
*
|
|
125
|
+
* (a) is for pipeline code with ctx in scope — the skip is visible at
|
|
126
|
+
* runtime. (b) is for utils without logger access — documentation in code.
|
|
127
|
+
* (c) catches the rare cases where TS narrowing leaves a dead return behind.
|
|
128
|
+
*/
|
|
129
|
+
function isAllowed(site: SkipSite): boolean {
|
|
130
|
+
const preceding = site.precedingText;
|
|
131
|
+
|
|
132
|
+
// (a) log call on ctx/context/opts.context or module-level logger
|
|
133
|
+
if (/\b(?:\w+\.)?(?:log|logger)\??\.(?:debug|info|warn|error|trace)\s*\(/.test(preceding)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// (b) explicit // skip: marker — check trailing comment on preceding OR leading on return
|
|
138
|
+
if (/\/\/\s*skip:/i.test(preceding)) return true;
|
|
139
|
+
if (hasLeadingSkipComment(site)) return true;
|
|
140
|
+
|
|
141
|
+
// (c) preceding is a throw
|
|
142
|
+
if (/^\s*throw\b/.test(preceding)) return true;
|
|
143
|
+
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasLeadingSkipComment(site: SkipSite): boolean {
|
|
148
|
+
// The ts-morph scanner captures the previous sibling as precedingText, but
|
|
149
|
+
// a `// skip:` comment directly above the return is leading trivia on the
|
|
150
|
+
// return statement itself — handled by describeSite via `commentsAbove`.
|
|
151
|
+
return /\/\/\s*skip:/i.test(site.commentsAbove ?? "");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Scanner + reporter
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export const guard: AstGuard = {
|
|
159
|
+
name: "Silent-Skip Guard",
|
|
160
|
+
scan: SCAN,
|
|
161
|
+
hint: "Nacktes `return;` braucht davor: Log-Call, `// skip: <grund>`-Kommentar, oder vorangehenden throw.",
|
|
162
|
+
run(files) {
|
|
163
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
164
|
+
|
|
165
|
+
for (const sf of files) {
|
|
166
|
+
const file = sf.getFilePath();
|
|
167
|
+
if (EXCLUDE.test(file)) continue;
|
|
168
|
+
|
|
169
|
+
const returns = sf.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
170
|
+
for (const ret of returns) {
|
|
171
|
+
if (ret.getExpression()) continue; // has a value, not "silent"
|
|
172
|
+
const site = describeSite(ret, sf);
|
|
173
|
+
if (isAllowed(site)) continue;
|
|
174
|
+
violations.push({
|
|
175
|
+
file: site.file,
|
|
176
|
+
line: site.line,
|
|
177
|
+
message: `[${site.enclosingKind}] in ${site.enclosingFunction}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { violations };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (import.meta.main) runStandalone(guard);
|