@cosmicdrift/kumiko-guards 0.1.0 → 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.
Files changed (54) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/git-env.ts +22 -0
  3. package/src/_lib/guard-kit.ts +74 -19
  4. package/src/_lib/qn.ts +21 -0
  5. package/src/_lib/roots.ts +1 -18
  6. package/src/_lib/security-baseline-cli.ts +3 -3
  7. package/src/_lib/security-baseline.ts +8 -8
  8. package/src/changes.json +40 -0
  9. package/src/check-as-casts.ts +648 -0
  10. package/src/check-complexity.ts +292 -0
  11. package/src/check-predicates.ts +218 -0
  12. package/src/check-secret-literals.ts +126 -0
  13. package/src/cli.ts +59 -0
  14. package/src/guard-admin-api.ts +1 -1
  15. package/src/guard-app-feature-structure.ts +114 -0
  16. package/src/guard-broker-subscribe.ts +99 -0
  17. package/src/guard-error-reasons.ts +185 -0
  18. package/src/guard-escape-hatch-declared.ts +26 -19
  19. package/src/guard-fake-tests.ts +1 -1
  20. package/src/guard-feature-integration-tests.ts +184 -0
  21. package/src/guard-html-escape.ts +1 -1
  22. package/src/guard-i18n-keys.ts +440 -0
  23. package/src/guard-i18n-locale-mount.ts +317 -0
  24. package/src/guard-i18n-locale-terminology.ts +117 -0
  25. package/src/guard-i18n-ui-strings.ts +248 -0
  26. package/src/guard-lib-test-coverage.ts +156 -0
  27. package/src/guard-loadall-events.ts +133 -0
  28. package/src/guard-no-custom-primitives.ts +9 -10
  29. package/src/guard-no-date-api.ts +1 -1
  30. package/src/guard-no-direct-fs.ts +1 -1
  31. package/src/guard-no-inline-styles.ts +4 -4
  32. package/src/guard-no-logic-in-views.ts +3 -3
  33. package/src/guard-no-raw-hooks.ts +4 -5
  34. package/src/guard-open-to-all-reason.ts +1 -1
  35. package/src/guard-pii-annotations.ts +267 -0
  36. package/src/guard-pre-es-patterns.ts +1 -1
  37. package/src/guard-primitives-discipline.ts +3 -3
  38. package/src/guard-raw-classname.ts +3 -3
  39. package/src/guard-raw-interactive-elements.ts +3 -3
  40. package/src/guard-raw-sql.ts +2 -2
  41. package/src/guard-renderer-boundaries.ts +1 -1
  42. package/src/guard-restricted-symbols.ts +1 -1
  43. package/src/guard-screen-conventions.ts +161 -0
  44. package/src/guard-silent-skip.ts +1 -1
  45. package/src/guard-table-ddl.ts +159 -0
  46. package/src/guard-tailwind-scan-surface.ts +12 -12
  47. package/src/guard-test-stack-drift.ts +147 -0
  48. package/src/guard-text-field-stance.ts +222 -0
  49. package/src/guard-thin-wrappers.ts +6 -1
  50. package/src/guard-unsafe-json-parse.ts +1 -1
  51. package/src/guard-write-handler-qns.ts +242 -0
  52. package/src/run-guards.ts +36 -3
  53. package/src/run-repo-checks.ts +10 -1
  54. 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
+ }