@cosmicdrift/kumiko-guards 0.1.1 → 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.
- package/package.json +4 -1
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +32 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Complexity-Check fuer Handler-Hotspots mit Baseline-Regression-Guard.
|
|
4
|
+
*
|
|
5
|
+
* Berechnet zyklomatische Komplexitaet pro Funktion/Methode (Basis 1, +1 je
|
|
6
|
+
* Entscheidungspunkt: if, for, while, case, catch, &&, ||, ??, Ternary).
|
|
7
|
+
* Ueber dem Schwellwert -> Hotspot.
|
|
8
|
+
*
|
|
9
|
+
* Baseline-Regression-Guard wie guard-comment-lang.ts: `.kumiko-complexity-
|
|
10
|
+
* baseline.json` im Repo-Root pinnt pro File die eingefrorene Hotspot-Anzahl.
|
|
11
|
+
* - aktuell <= baseline pro File: PASS
|
|
12
|
+
* - aktuell > baseline pro File: FAIL
|
|
13
|
+
* Reduktionen updaten die Baseline NICHT automatisch — nach Refactor-Commits
|
|
14
|
+
* `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Check
|
|
15
|
+
* warning-only (Bootstrap: einmalig `--write-baseline`).
|
|
16
|
+
* Die Baseline umfasst nur Files des eigenen Repos; Sibling-Hotspots gehoeren
|
|
17
|
+
* in deren eigene Baseline (siehe localHotspots).
|
|
18
|
+
*
|
|
19
|
+
* Bewusste Luecke: gezaehlt werden Hotspots pro File, nicht deren Hoehe — eine
|
|
20
|
+
* bereits gelistete Funktion darf komplexer werden, ohne dass der Guard faellt.
|
|
21
|
+
*
|
|
22
|
+
* Opt-out: `// kumiko-lint-ignore complexity-budget <Grund>` auf der
|
|
23
|
+
* Funktionszeile, der Zeile darueber, oder (bei JSDoc-dokumentierten
|
|
24
|
+
* Funktionen) der Zeile ueber dem JSDoc-Block.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* bun guards/check-complexity.ts # Vergleich gegen Baseline
|
|
28
|
+
* bun guards/check-complexity.ts --write-baseline # Baseline neu schreiben
|
|
29
|
+
* bun guards/check-complexity.ts --no-baseline # Vergleich ueberspringen
|
|
30
|
+
*
|
|
31
|
+
* Regel: Issue kumiko-framework#1282
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
36
|
+
import {
|
|
37
|
+
type AstGuard,
|
|
38
|
+
baselineRatchet,
|
|
39
|
+
buildSharedProject,
|
|
40
|
+
filesForGuard,
|
|
41
|
+
type GuardOutcome,
|
|
42
|
+
type GuardViolation,
|
|
43
|
+
isLocalFinding,
|
|
44
|
+
runStandalone,
|
|
45
|
+
type ScanSpec,
|
|
46
|
+
} from "./_lib/guard-kit";
|
|
47
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
48
|
+
|
|
49
|
+
const ROOT = process.cwd();
|
|
50
|
+
|
|
51
|
+
const SCAN: ScanSpec = {
|
|
52
|
+
scope: "source",
|
|
53
|
+
extensions: ["ts"],
|
|
54
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
58
|
+
|
|
59
|
+
const COMPLEXITY_THRESHOLD = 15;
|
|
60
|
+
const MAX_REPORTED = 30;
|
|
61
|
+
const BUDGET_TAG = "kumiko-lint-ignore complexity-budget";
|
|
62
|
+
|
|
63
|
+
const FUNCTION_KINDS = [
|
|
64
|
+
SyntaxKind.FunctionDeclaration,
|
|
65
|
+
SyntaxKind.FunctionExpression,
|
|
66
|
+
SyntaxKind.ArrowFunction,
|
|
67
|
+
SyntaxKind.MethodDeclaration,
|
|
68
|
+
SyntaxKind.Constructor,
|
|
69
|
+
SyntaxKind.GetAccessor,
|
|
70
|
+
SyntaxKind.SetAccessor,
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
export interface Hotspot {
|
|
74
|
+
file: string;
|
|
75
|
+
line: number;
|
|
76
|
+
name: string;
|
|
77
|
+
complexity: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function functionName(node: Node): string {
|
|
81
|
+
if (
|
|
82
|
+
Node.isFunctionDeclaration(node) ||
|
|
83
|
+
Node.isMethodDeclaration(node) ||
|
|
84
|
+
Node.isGetAccessorDeclaration(node) ||
|
|
85
|
+
Node.isSetAccessorDeclaration(node)
|
|
86
|
+
) {
|
|
87
|
+
return node.getName() ?? "<anonymous>";
|
|
88
|
+
}
|
|
89
|
+
if (Node.isConstructorDeclaration(node)) return "constructor";
|
|
90
|
+
// Arrow/function-expression assigned to a variable, object property, or
|
|
91
|
+
// class property (`foo = () => {...}`): use that name.
|
|
92
|
+
const parent = node.getParent();
|
|
93
|
+
if (
|
|
94
|
+
Node.isVariableDeclaration(parent) ||
|
|
95
|
+
Node.isPropertyAssignment(parent) ||
|
|
96
|
+
Node.isPropertyDeclaration(parent)
|
|
97
|
+
) {
|
|
98
|
+
return parent.getName();
|
|
99
|
+
}
|
|
100
|
+
return "<anonymous>";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// hasIgnoreTag() only checks the node's own start line and the line above —
|
|
104
|
+
// for a JSDoc-documented function, "the line above" is the JSDoc's closing
|
|
105
|
+
// `*/`, not a line where a developer would naturally place a bare `//` tag.
|
|
106
|
+
// Extend the check to the line above the JSDoc block too.
|
|
107
|
+
function hasBudgetTag(fn: Node): boolean {
|
|
108
|
+
if (hasIgnoreTag(fn, BUDGET_TAG)) return true;
|
|
109
|
+
const jsDocStartLine = fn.getStartLineNumber(true);
|
|
110
|
+
if (jsDocStartLine === fn.getStartLineNumber()) return false;
|
|
111
|
+
const lines = fn.getSourceFile().getFullText().split("\n");
|
|
112
|
+
return (lines[jsDocStartLine - 2] ?? "").includes(BUDGET_TAG);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function computeComplexity(fn: Node): number {
|
|
116
|
+
let complexity = 1;
|
|
117
|
+
fn.forEachDescendant((node, traversal) => {
|
|
118
|
+
// Don't count decision points inside a nested function-like node —
|
|
119
|
+
// those get their own Hotspot entry.
|
|
120
|
+
if (node !== fn && (FUNCTION_KINDS as readonly SyntaxKind[]).includes(node.getKind())) {
|
|
121
|
+
traversal.skip();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
switch (node.getKind()) {
|
|
125
|
+
case SyntaxKind.IfStatement:
|
|
126
|
+
case SyntaxKind.ForStatement:
|
|
127
|
+
case SyntaxKind.ForInStatement:
|
|
128
|
+
case SyntaxKind.ForOfStatement:
|
|
129
|
+
case SyntaxKind.WhileStatement:
|
|
130
|
+
case SyntaxKind.DoStatement:
|
|
131
|
+
case SyntaxKind.CatchClause:
|
|
132
|
+
case SyntaxKind.ConditionalExpression:
|
|
133
|
+
complexity++;
|
|
134
|
+
break;
|
|
135
|
+
case SyntaxKind.CaseClause:
|
|
136
|
+
complexity++;
|
|
137
|
+
break;
|
|
138
|
+
case SyntaxKind.BinaryExpression: {
|
|
139
|
+
const op = node.asKindOrThrow(SyntaxKind.BinaryExpression).getOperatorToken().getKind();
|
|
140
|
+
if (
|
|
141
|
+
op === SyntaxKind.AmpersandAmpersandToken ||
|
|
142
|
+
op === SyntaxKind.BarBarToken ||
|
|
143
|
+
op === SyntaxKind.QuestionQuestionToken
|
|
144
|
+
) {
|
|
145
|
+
complexity++;
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return complexity;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function collectHotspots(sf: SourceFile): Hotspot[] {
|
|
157
|
+
const file = path.relative(ROOT, sf.getFilePath());
|
|
158
|
+
const hotspots: Hotspot[] = [];
|
|
159
|
+
for (const kind of FUNCTION_KINDS) {
|
|
160
|
+
for (const fn of sf.getDescendantsOfKind(kind)) {
|
|
161
|
+
const complexity = computeComplexity(fn);
|
|
162
|
+
if (complexity < COMPLEXITY_THRESHOLD) continue;
|
|
163
|
+
if (hasBudgetTag(fn)) continue;
|
|
164
|
+
hotspots.push({
|
|
165
|
+
file,
|
|
166
|
+
line: fn.getStartLineNumber(),
|
|
167
|
+
name: functionName(fn),
|
|
168
|
+
complexity,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return hotspots;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const BASELINE_FILE = ".kumiko-complexity-baseline.json";
|
|
176
|
+
|
|
177
|
+
const BASELINE_FORMAT_VERSION = 1;
|
|
178
|
+
|
|
179
|
+
// The baseline lives inside one repo and may only gate that repo's own files.
|
|
180
|
+
// The guard scans every root, so sibling hits appear as "../<repo>/..." relative
|
|
181
|
+
// to ROOT: freezing those would fail a money-horse refactor locally in the
|
|
182
|
+
// framework (invisible in CI, where no sibling is checked out), and would leak
|
|
183
|
+
// private repo paths into the public framework history. The report still lists
|
|
184
|
+
// every hotspot — only the baseline view is repo-local.
|
|
185
|
+
export function localHotspots(hotspots: readonly Hotspot[]): readonly Hotspot[] {
|
|
186
|
+
return hotspots.filter(isLocalFinding);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function countHotspotsByFile(hotspots: readonly Hotspot[]): Record<string, number> {
|
|
190
|
+
const counts: Record<string, number> = {};
|
|
191
|
+
for (const h of hotspots) counts[h.file] = (counts[h.file] ?? 0) + 1;
|
|
192
|
+
return counts;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function scan(files: readonly SourceFile[]): {
|
|
196
|
+
hotspots: Hotspot[];
|
|
197
|
+
scanned: number;
|
|
198
|
+
} {
|
|
199
|
+
const hotspots: Hotspot[] = [];
|
|
200
|
+
let scanned = 0;
|
|
201
|
+
for (const sf of files) {
|
|
202
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
203
|
+
scanned++;
|
|
204
|
+
hotspots.push(...collectHotspots(sf));
|
|
205
|
+
}
|
|
206
|
+
hotspots.sort((a, b) => b.complexity - a.complexity);
|
|
207
|
+
return { hotspots, scanned };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function report(hotspots: readonly Hotspot[], scanned: number): void {
|
|
211
|
+
console.log(`Complexity Check: ${scanned} files checked.`);
|
|
212
|
+
console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
|
|
213
|
+
if (hotspots.length === 0) {
|
|
214
|
+
console.log(" Nothing to report.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const shown = hotspots.slice(0, MAX_REPORTED);
|
|
218
|
+
for (const h of shown) {
|
|
219
|
+
console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
|
|
220
|
+
}
|
|
221
|
+
if (hotspots.length > shown.length) {
|
|
222
|
+
console.log(` ... ${hotspots.length - shown.length} more`);
|
|
223
|
+
}
|
|
224
|
+
console.log(
|
|
225
|
+
`\n Rule: split the function, or deliberately allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const complexityBaseline = baselineRatchet({
|
|
230
|
+
file: path.join(ROOT, BASELINE_FILE),
|
|
231
|
+
formatVersion: BASELINE_FORMAT_VERSION,
|
|
232
|
+
unit: "Complexity-Hotspot(s)",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// `scan` sorts by complexity descending, so the first hit for a file is its
|
|
236
|
+
// worst function — the one the violation should point at, not whichever
|
|
237
|
+
// hotspot happens to come first in the file.
|
|
238
|
+
export function resolveHotspotLine(hotspots: readonly Hotspot[], file: string): number {
|
|
239
|
+
return hotspots.find((h) => h.file === file)?.line ?? 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
|
|
243
|
+
const hotspots = localHotspots(scanned);
|
|
244
|
+
return complexityBaseline.check(
|
|
245
|
+
countHotspotsByFile(hotspots),
|
|
246
|
+
`Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
247
|
+
{
|
|
248
|
+
formatDriftRemediation: `Run \`bun guards/check-complexity.ts --write-baseline\` once.`,
|
|
249
|
+
resolveLine: (file) => resolveHotspotLine(hotspots, file),
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
255
|
+
const { hotspots, scanned } = scan(files);
|
|
256
|
+
report(hotspots, scanned);
|
|
257
|
+
if (!compareBaseline) {
|
|
258
|
+
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
259
|
+
return { violations: [] };
|
|
260
|
+
}
|
|
261
|
+
return { violations: checkBaseline(hotspots) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const guard: AstGuard = {
|
|
265
|
+
name: "Complexity Check",
|
|
266
|
+
scan: SCAN,
|
|
267
|
+
// Kein Remediation-Text hier: reportResults haengt hint an JEDEN Fail, auch
|
|
268
|
+
// an Format-Drift, wo "Funktion aufteilen" in die Irre fuehrt. Der konkrete
|
|
269
|
+
// Rat steht deshalb in der jeweiligen Violation-Message.
|
|
270
|
+
hint: "after a deliberate change: `bun guards/check-complexity.ts --write-baseline`",
|
|
271
|
+
run: (files) => analyse(files, true),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
|
|
275
|
+
// (run-guards.ts) faehrt 18 Guards mit derselben argv, ein --write-baseline
|
|
276
|
+
// dort duerfte die Baseline nicht stillschweigend neu schreiben.
|
|
277
|
+
if (import.meta.main) {
|
|
278
|
+
const args = process.argv.slice(2);
|
|
279
|
+
if (args.includes("--write-baseline")) {
|
|
280
|
+
const project = buildSharedProject([guard]);
|
|
281
|
+
const { hotspots, scanned } = scan(filesForGuard(project, guard));
|
|
282
|
+
report(hotspots, scanned);
|
|
283
|
+
complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
if (args.includes("--no-baseline")) {
|
|
287
|
+
const project = buildSharedProject([guard]);
|
|
288
|
+
analyse(filesForGuard(project, guard), false);
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
runStandalone(guard);
|
|
292
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Predicate-Extraction Check (WARNUNG, kein Fail).
|
|
4
|
+
*
|
|
5
|
+
* Findet zwei Arten von Kandidaten fuer Predicate-Extraction:
|
|
6
|
+
* 1. Fat Predicates — `if`/`while`/ternary Conditions mit >=3 logischen
|
|
7
|
+
* Operatoren (&&/||) ODER Condition-Text >80 Zeichen.
|
|
8
|
+
* 2. Duplikate — dieselbe Condition (normalisiert) >=2x im Scan-Scope,
|
|
9
|
+
* mit >=2 Operatoren (um triviale `!x`-Checks auszuschliessen).
|
|
10
|
+
*
|
|
11
|
+
* Output: Warnung mit Datei:Zeile + Hinweis auf Regel. Exit-Code immer 0.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* bun guards/check-predicates.ts
|
|
15
|
+
*
|
|
16
|
+
* Regel: ~/.claude/rules/coding-standards.md → "Predicate Extraction"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
22
|
+
|
|
23
|
+
const ROOT = process.cwd();
|
|
24
|
+
|
|
25
|
+
const SCAN: ScanSpec = {
|
|
26
|
+
scope: "source",
|
|
27
|
+
extensions: ["ts"],
|
|
28
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
32
|
+
|
|
33
|
+
const OPERATOR_THRESHOLD = 3;
|
|
34
|
+
const LENGTH_THRESHOLD = 80;
|
|
35
|
+
const DUP_MIN_OPERATORS = 2;
|
|
36
|
+
|
|
37
|
+
interface Site {
|
|
38
|
+
file: string;
|
|
39
|
+
line: number;
|
|
40
|
+
text: string;
|
|
41
|
+
operators: number;
|
|
42
|
+
ands: number;
|
|
43
|
+
ors: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalize(text: string): string {
|
|
47
|
+
return text.replace(/\s+/g, " ").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Keywords/primitives we keep literal when building the structural shape.
|
|
51
|
+
// Everything else that looks like an identifier or property access collapses to `_`,
|
|
52
|
+
// so `toKebab(x) !== x` and `toKebab(y) !== y` share the same shape.
|
|
53
|
+
const SHAPE_KEEP = new Set([
|
|
54
|
+
"typeof",
|
|
55
|
+
"instanceof",
|
|
56
|
+
"in",
|
|
57
|
+
"of",
|
|
58
|
+
"new",
|
|
59
|
+
"void",
|
|
60
|
+
"delete",
|
|
61
|
+
"await",
|
|
62
|
+
"true",
|
|
63
|
+
"false",
|
|
64
|
+
"null",
|
|
65
|
+
"undefined",
|
|
66
|
+
"string",
|
|
67
|
+
"number",
|
|
68
|
+
"object",
|
|
69
|
+
"boolean",
|
|
70
|
+
"symbol",
|
|
71
|
+
"bigint",
|
|
72
|
+
"function",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
function structuralShape(text: string): string {
|
|
76
|
+
return text.replace(/\b[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*\b/g, (match) => {
|
|
77
|
+
const dot = match.indexOf(".");
|
|
78
|
+
const head = dot === -1 ? match : match.slice(0, dot);
|
|
79
|
+
return SHAPE_KEEP.has(head) ? match : "_";
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function countOperators(text: string): { ands: number; ors: number } {
|
|
84
|
+
const ands = (text.match(/&&/g) ?? []).length;
|
|
85
|
+
const ors = (text.match(/\|\|/g) ?? []).length;
|
|
86
|
+
return { ands, ors };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectConditions(sf: SourceFile): Site[] {
|
|
90
|
+
const sites: Site[] = [];
|
|
91
|
+
const file = path.relative(ROOT, sf.getFilePath());
|
|
92
|
+
|
|
93
|
+
const push = (node: Node, conditionNode: Node | undefined): void => {
|
|
94
|
+
if (!conditionNode) return;
|
|
95
|
+
const raw = conditionNode.getText();
|
|
96
|
+
const text = normalize(raw);
|
|
97
|
+
const { ands, ors } = countOperators(text);
|
|
98
|
+
const operators = ands + ors;
|
|
99
|
+
if (operators === 0 && text.length < LENGTH_THRESHOLD) return;
|
|
100
|
+
sites.push({
|
|
101
|
+
file,
|
|
102
|
+
line: node.getStartLineNumber(),
|
|
103
|
+
text,
|
|
104
|
+
operators,
|
|
105
|
+
ands,
|
|
106
|
+
ors,
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const ifNode of sf.getDescendantsOfKind(SyntaxKind.IfStatement)) {
|
|
111
|
+
push(ifNode, ifNode.getExpression());
|
|
112
|
+
}
|
|
113
|
+
for (const whileNode of sf.getDescendantsOfKind(SyntaxKind.WhileStatement)) {
|
|
114
|
+
push(whileNode, whileNode.getExpression());
|
|
115
|
+
}
|
|
116
|
+
for (const tern of sf.getDescendantsOfKind(SyntaxKind.ConditionalExpression)) {
|
|
117
|
+
push(tern, tern.getCondition());
|
|
118
|
+
}
|
|
119
|
+
return sites;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Warning-only Check: blockt nie (returnt immer 0 violations). Die Findings
|
|
123
|
+
// werden via console ausgegeben; der Runner zeigt `✓` solange nichts wirft.
|
|
124
|
+
export const guard: AstGuard = {
|
|
125
|
+
name: "Predicate Extraction Check",
|
|
126
|
+
scan: SCAN,
|
|
127
|
+
run(files) {
|
|
128
|
+
const allSites: Site[] = [];
|
|
129
|
+
let scanned = 0;
|
|
130
|
+
for (const sf of files) {
|
|
131
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
132
|
+
scanned++;
|
|
133
|
+
allSites.push(...collectConditions(sf));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fatSites = allSites.filter(
|
|
137
|
+
(s) => s.operators >= OPERATOR_THRESHOLD || s.text.length >= LENGTH_THRESHOLD,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const byText = new Map<string, Site[]>();
|
|
141
|
+
const byShape = new Map<string, Site[]>();
|
|
142
|
+
for (const s of allSites) {
|
|
143
|
+
// Count as duplicate candidate if either >=2 operators OR text long enough
|
|
144
|
+
// that repetition is still worth extracting (covers 1-operator but lengthy
|
|
145
|
+
// domain checks like kebab-case validation).
|
|
146
|
+
if (s.operators < DUP_MIN_OPERATORS && s.text.length < LENGTH_THRESHOLD) continue;
|
|
147
|
+
const textBucket = byText.get(s.text) ?? [];
|
|
148
|
+
textBucket.push(s);
|
|
149
|
+
byText.set(s.text, textBucket);
|
|
150
|
+
const shape = structuralShape(s.text);
|
|
151
|
+
const shapeBucket = byShape.get(shape) ?? [];
|
|
152
|
+
shapeBucket.push(s);
|
|
153
|
+
byShape.set(shape, shapeBucket);
|
|
154
|
+
}
|
|
155
|
+
const textDups: Array<[Site, Site, ...Site[]]> = [];
|
|
156
|
+
for (const bucket of byText.values()) {
|
|
157
|
+
if (bucket.length < 2) continue;
|
|
158
|
+
const [first, second, ...rest] = bucket;
|
|
159
|
+
if (first !== undefined && second !== undefined) textDups.push([first, second, ...rest]);
|
|
160
|
+
}
|
|
161
|
+
const shapeDups: Array<{ shape: string; sites: Site[] }> = [];
|
|
162
|
+
for (const [shape, bucket] of byShape.entries()) {
|
|
163
|
+
if (bucket.length < 2) continue;
|
|
164
|
+
// Skip when sites are already covered by exact-text duplicate group.
|
|
165
|
+
const firstText = bucket[0]?.text;
|
|
166
|
+
const allSame = bucket.every((s) => s.text === firstText);
|
|
167
|
+
if (allSame) continue;
|
|
168
|
+
shapeDups.push({ shape, sites: bucket });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`Predicate-Extraction Check: ${scanned} files checked.`);
|
|
172
|
+
console.log(
|
|
173
|
+
` Fat predicates (>=${OPERATOR_THRESHOLD} ops or >${LENGTH_THRESHOLD} chars): ${fatSites.length}`,
|
|
174
|
+
);
|
|
175
|
+
console.log(` Exact duplicates: ${textDups.length}`);
|
|
176
|
+
console.log(` Structural duplicates (same pattern, different names): ${shapeDups.length}`);
|
|
177
|
+
|
|
178
|
+
if (fatSites.length === 0 && textDups.length === 0 && shapeDups.length === 0) {
|
|
179
|
+
console.log(" Nothing to report.");
|
|
180
|
+
return { violations: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const snip = (text: string): string => (text.length > 90 ? `${text.slice(0, 87)}...` : text);
|
|
184
|
+
|
|
185
|
+
if (fatSites.length > 0) {
|
|
186
|
+
console.log(`\n Fat-predicate candidates:`);
|
|
187
|
+
for (const s of fatSites) {
|
|
188
|
+
const hint = s.ands === 0 && s.ors >= 3 ? " (Array.includes/Set?)" : "";
|
|
189
|
+
console.log(` ${s.file}:${s.line} [${s.ands}&& ${s.ors}||] ${snip(s.text)}${hint}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (textDups.length > 0) {
|
|
194
|
+
console.log(`\n Exact duplicates:`);
|
|
195
|
+
for (const group of textDups) {
|
|
196
|
+
const [first] = group;
|
|
197
|
+
console.log(` ${group.length}x ${snip(first.text)}`);
|
|
198
|
+
for (const s of group) console.log(` - ${s.file}:${s.line}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (shapeDups.length > 0) {
|
|
203
|
+
console.log(`\n Structural duplicates:`);
|
|
204
|
+
for (const { shape, sites } of shapeDups) {
|
|
205
|
+
console.log(` ${sites.length}x shape: ${snip(shape)}`);
|
|
206
|
+
for (const s of sites) console.log(` - ${s.file}:${s.line} ${snip(s.text)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(
|
|
211
|
+
"\n Rule: extract as a named function (isX/hasY/canZ) when the condition has a stable name.",
|
|
212
|
+
);
|
|
213
|
+
console.log(" Warning, no fail.");
|
|
214
|
+
return { violations: [] };
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Secret-Literal-Guard (stack-agnostisch).
|
|
4
|
+
*
|
|
5
|
+
* Flaggt hartkodierte Secret-Fallbacks in Server-Code:
|
|
6
|
+
* const s = env.JWT_SECRET ?? "hardcoded-prod-secret"; // ✗
|
|
7
|
+
* hmacSecret: config.X ?? "cashcolt-mailer-hmac-secret", // ✗
|
|
8
|
+
*
|
|
9
|
+
* Ein `?? "<literal>"`-Fallback auf etwas Secret-artiges heißt: fehlt die
|
|
10
|
+
* Env-Variable, läuft der Server mit einem im Repo sichtbaren Secret weiter —
|
|
11
|
+
* genau die Klasse, die bei einem Deploy-Fehler zum geleakten Prod-Secret wird.
|
|
12
|
+
* Secure-by-default: fehlendes Secret → hart fehlschlagen, nie auf ein Literal
|
|
13
|
+
* zurückfallen.
|
|
14
|
+
*
|
|
15
|
+
* AKZEPTIERTE AUSNAHME: `bin/server.ts` ist der designierte DEV-Entrypoint
|
|
16
|
+
* (runDevApp). Dort sind Dev-Secret-Fallbacks gewollt — der PROD-Entrypoint
|
|
17
|
+
* `bin/main.ts` (runProdApp) liest dieselben Secrets aus dem validierten Env
|
|
18
|
+
* und failt hart. Der Guard akzeptiert Literale daher nur in `bin/server.ts`
|
|
19
|
+
* und flaggt sie überall sonst (Prod-Entrypoint, Config-Module, Handler, Lib).
|
|
20
|
+
*
|
|
21
|
+
* Scannt den eigenen Checkout (die `roots`, die der Runner auflöst) —
|
|
22
|
+
* jedes Repo läuft diesen Check in seiner eigenen CI gegen sich selbst,
|
|
23
|
+
* statt dass ein zentraler Scan in fremde Sibling-Checkouts greift.
|
|
24
|
+
*/
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { Glob } from "bun";
|
|
27
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
28
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
29
|
+
import { scanLinesForPredicate } from "./_lib/scan-lines";
|
|
30
|
+
|
|
31
|
+
// Server-side only: apps/server (not apps/mobile — client code, no server secrets).
|
|
32
|
+
const SCAN_PATTERNS: ReadonlyArray<string> = [
|
|
33
|
+
"bin/**/*.ts",
|
|
34
|
+
"src/**/*.ts",
|
|
35
|
+
"apps/server/src/**/*.ts",
|
|
36
|
+
"packages/*/src/**/*.ts",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const EXCLUDE_DIR = /(?:^|\/)(?:node_modules|dist|__tests__)\//;
|
|
40
|
+
const IS_TEST = /\.(?:test|integration)\.tsx?$/;
|
|
41
|
+
// The one accepted home for dev-secret fallbacks (see header).
|
|
42
|
+
const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
|
|
43
|
+
|
|
44
|
+
// A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
|
|
45
|
+
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/;
|
|
46
|
+
// ...on a line that is about a secret (assignee name or the literal itself).
|
|
47
|
+
const SECRET_CONTEXT = /secret|password|passphrase|hmac|private[_-]?key|signing[_-]?key/i;
|
|
48
|
+
// Short / numeric literals are versions or flags (e.g. `_CURRENT_VERSION ?? "1"`),
|
|
49
|
+
// never a usable secret.
|
|
50
|
+
const TRIVIAL_LITERAL = /^[\d.]+$/;
|
|
51
|
+
|
|
52
|
+
export type SecretLiteralFinding = {
|
|
53
|
+
readonly file: string;
|
|
54
|
+
readonly line: number;
|
|
55
|
+
readonly text: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Returns the offending literal, or null when the line is clean. */
|
|
59
|
+
export function secretLiteralOnLine(line: string): string | null {
|
|
60
|
+
// Only the line start counts as a comment (`//`, or a block-comment line
|
|
61
|
+
// starting with `*`/`/*`/`*/`): stripping from the first `//` anywhere in
|
|
62
|
+
// the line would cut connection-string fallbacks (postgres://, redis://,
|
|
63
|
+
// https://token@host/…) off before their closing quote, losing findings.
|
|
64
|
+
const code = /^\s*(?:\/\/|\/\*|\*\/|\*)/.test(line) ? "" : line;
|
|
65
|
+
if (!SECRET_CONTEXT.test(code)) return null;
|
|
66
|
+
const match = STRING_FALLBACK.exec(code);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
const literal = match[2];
|
|
69
|
+
if (!literal || literal.length < 8 || TRIVIAL_LITERAL.test(literal)) return null;
|
|
70
|
+
return literal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function scanRoot(
|
|
74
|
+
root: RepoRoot,
|
|
75
|
+
): Promise<{ readonly findings: SecretLiteralFinding[]; readonly scannedFiles: number }> {
|
|
76
|
+
const findings: SecretLiteralFinding[] = [];
|
|
77
|
+
let scannedFiles = 0;
|
|
78
|
+
for (const pattern of SCAN_PATTERNS) {
|
|
79
|
+
for (const rel of new Glob(pattern).scanSync({ cwd: root.absPath })) {
|
|
80
|
+
if (EXCLUDE_DIR.test(`/${rel}`) || IS_TEST.test(rel) || DEV_ENTRYPOINT.test(rel)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const abs = join(root.absPath, rel);
|
|
84
|
+
scannedFiles++;
|
|
85
|
+
scanLinesForPredicate(abs, rel, (line) => secretLiteralOnLine(line) !== null, findings);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { findings, scannedFiles };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function scanSecretLiterals(roots: readonly RepoRoot[]): Promise<{
|
|
92
|
+
readonly findings: SecretLiteralFinding[];
|
|
93
|
+
readonly scannedFiles: number;
|
|
94
|
+
}> {
|
|
95
|
+
const findings: SecretLiteralFinding[] = [];
|
|
96
|
+
let scannedFiles = 0;
|
|
97
|
+
for (const root of roots) {
|
|
98
|
+
const result = await scanRoot(root);
|
|
99
|
+
findings.push(...result.findings);
|
|
100
|
+
scannedFiles += result.scannedFiles;
|
|
101
|
+
}
|
|
102
|
+
return { findings, scannedFiles };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const check: RepoCheck = {
|
|
106
|
+
name: "Secret-Literal Guard",
|
|
107
|
+
hint:
|
|
108
|
+
"A missing secret must fail hard, never fall back to a literal. " +
|
|
109
|
+
"Read from the validated env (throw if missing). Dev-only fallbacks belong in bin/server.ts.",
|
|
110
|
+
async run(roots) {
|
|
111
|
+
if (roots.length === 0) {
|
|
112
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
113
|
+
}
|
|
114
|
+
const { findings, scannedFiles } = await scanSecretLiterals(roots);
|
|
115
|
+
return {
|
|
116
|
+
violations: findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
|
|
117
|
+
matchedFiles: scannedFiles,
|
|
118
|
+
notApplicable: false,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (import.meta.main) {
|
|
124
|
+
const failed = reportResults(await runRepoChecks([check], resolveRepoRoots()));
|
|
125
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
126
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Consumer entry point for `bunx @cosmicdrift/kumiko-guards`. Runs all three
|
|
3
|
+
// suites (or one, via subcommand) using the exact call forms the three
|
|
4
|
+
// runners already use in their own `if (import.meta.main)` blocks.
|
|
5
|
+
import {
|
|
6
|
+
buildSharedProject,
|
|
7
|
+
printGuardKitBanner,
|
|
8
|
+
reportResults,
|
|
9
|
+
runGuards,
|
|
10
|
+
runRepoChecks,
|
|
11
|
+
} from "./_lib/guard-kit";
|
|
12
|
+
import { GUARDS } from "./run-guards";
|
|
13
|
+
import { REPO_CHECKS } from "./run-repo-checks";
|
|
14
|
+
import { UI_GUARDS } from "./run-ui-guards";
|
|
15
|
+
|
|
16
|
+
const SUBCOMMANDS = ["guards", "ui", "checks"] as const;
|
|
17
|
+
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
18
|
+
|
|
19
|
+
function isSubcommand(value: string): value is Subcommand {
|
|
20
|
+
return (SUBCOMMANDS as readonly string[]).includes(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runGuardsSuite(): number {
|
|
24
|
+
const project = buildSharedProject(GUARDS);
|
|
25
|
+
printGuardKitBanner(GUARDS.length, project);
|
|
26
|
+
return reportResults(runGuards(GUARDS, project));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runUiGuardsSuite(): number {
|
|
30
|
+
const project = buildSharedProject(UI_GUARDS);
|
|
31
|
+
printGuardKitBanner(UI_GUARDS.length, project);
|
|
32
|
+
return reportResults(runGuards(UI_GUARDS, project));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runRepoChecksSuite(): Promise<number> {
|
|
36
|
+
printGuardKitBanner(REPO_CHECKS.length);
|
|
37
|
+
return reportResults(await runRepoChecks(REPO_CHECKS));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main(): Promise<void> {
|
|
41
|
+
const subcommand = process.argv[2];
|
|
42
|
+
if (subcommand !== undefined && !isSubcommand(subcommand)) {
|
|
43
|
+
console.error(
|
|
44
|
+
`Unknown subcommand "${subcommand}". Valid subcommands: ${SUBCOMMANDS.join(", ")}`,
|
|
45
|
+
);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let failed = 0;
|
|
50
|
+
if (subcommand === undefined || subcommand === "guards") failed += runGuardsSuite();
|
|
51
|
+
if (subcommand === undefined || subcommand === "ui") failed += runUiGuardsSuite();
|
|
52
|
+
if (subcommand === undefined || subcommand === "checks") failed += await runRepoChecksSuite();
|
|
53
|
+
|
|
54
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (import.meta.main) {
|
|
58
|
+
await main();
|
|
59
|
+
}
|