@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.
Files changed (47) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +16 -0
  3. package/package.json +40 -0
  4. package/src/_lib/baseline-compare.ts +56 -0
  5. package/src/_lib/generic-reason.ts +39 -0
  6. package/src/_lib/guard-kit.ts +534 -0
  7. package/src/_lib/handler-name-forms.ts +29 -0
  8. package/src/_lib/ignore-tag.ts +24 -0
  9. package/src/_lib/primitives-access.ts +19 -0
  10. package/src/_lib/roots.ts +304 -0
  11. package/src/_lib/scan-lines.ts +25 -0
  12. package/src/_lib/scan-scope.ts +152 -0
  13. package/src/_lib/security-baseline-cli.ts +54 -0
  14. package/src/_lib/security-baseline.ts +325 -0
  15. package/src/_lib/sql-inventory.ts +267 -0
  16. package/src/guard-access-denied-test.ts +135 -0
  17. package/src/guard-admin-api.ts +134 -0
  18. package/src/guard-cross-feature-imports.ts +244 -0
  19. package/src/guard-direct-entity-writes.ts +387 -0
  20. package/src/guard-direct-fetch.ts +154 -0
  21. package/src/guard-escape-hatch-declared.ts +520 -0
  22. package/src/guard-fake-tests.ts +137 -0
  23. package/src/guard-html-escape.ts +345 -0
  24. package/src/guard-no-custom-primitives.ts +196 -0
  25. package/src/guard-no-date-api.ts +186 -0
  26. package/src/guard-no-direct-fs.ts +232 -0
  27. package/src/guard-no-direct-process-env.ts +126 -0
  28. package/src/guard-no-inline-styles.ts +58 -0
  29. package/src/guard-no-logic-in-views.ts +147 -0
  30. package/src/guard-no-raw-hooks.ts +76 -0
  31. package/src/guard-open-to-all-reason.ts +112 -0
  32. package/src/guard-pre-es-patterns.ts +199 -0
  33. package/src/guard-primitives-discipline.ts +330 -0
  34. package/src/guard-raw-classname.ts +111 -0
  35. package/src/guard-raw-interactive-elements.ts +154 -0
  36. package/src/guard-raw-sql.ts +89 -0
  37. package/src/guard-renderer-boundaries.ts +157 -0
  38. package/src/guard-restricted-symbols.ts +138 -0
  39. package/src/guard-silent-skip.ts +186 -0
  40. package/src/guard-tailwind-scan-surface.ts +588 -0
  41. package/src/guard-tenant-escalation.ts +312 -0
  42. package/src/guard-thin-wrappers.ts +422 -0
  43. package/src/guard-unsafe-json-parse.ts +86 -0
  44. package/src/index.ts +29 -0
  45. package/src/run-guards.ts +78 -0
  46. package/src/run-repo-checks.ts +22 -0
  47. package/src/run-ui-guards.ts +25 -0
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Shared-Project Guard-Kit.
3
+ *
4
+ * Before: every ts-morph guard was its own `bun <guard>.ts` subprocess,
5
+ * building its own `new Project(...)` (~1.1GB RSS) and re-parsing all repo
6
+ * sources. At pool=6, six such projects ran at once → ~7GB → memory
7
+ * thrashing (the "96s" guard times were swap, not CPU).
8
+ *
9
+ * Now: ONE project, built once, all AST guards run serially in-process over
10
+ * it. roots.ts resolution stays intact — every guard now declares its
11
+ * scope via `AstGuard.scan` (see scan-scope.ts), resolved against each root's
12
+ * kumiko.json manifest instead of hardcoded repo-kind globs.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { relative as pathRelative } from "node:path";
17
+ import { Project, type SourceFile } from "ts-morph";
18
+ import { compareToBaseline, findRepoRootFor } from "./baseline-compare";
19
+ import {
20
+ explainRepoRoots,
21
+ frameworkTsConfigPath,
22
+ type RepoRoot,
23
+ type RootResolution,
24
+ resolveRepoRoots,
25
+ } from "./roots";
26
+ import { type RootScan, type ScanSpec, scanFiles, scanRoots } from "./scan-scope";
27
+ import {
28
+ applySecurityBaseline,
29
+ loadSecurityBaseline,
30
+ type SecurityBaselineLoad,
31
+ } from "./security-baseline";
32
+
33
+ export { type BaselineRegression, compareToBaseline, findRepoRootFor } from "./baseline-compare";
34
+ export type { ScanExtension, ScanScope, ScanSpec } from "./scan-scope";
35
+
36
+ export type GuardViolation = {
37
+ readonly file: string;
38
+ readonly line: number;
39
+ readonly message: string;
40
+ /** Hard finding no security baseline may freeze, e.g. a placeholder reason. */
41
+ readonly neverFrozen?: boolean;
42
+ };
43
+
44
+ export type GuardOutcome = {
45
+ readonly violations: readonly GuardViolation[];
46
+ };
47
+
48
+ export type AstGuard = {
49
+ readonly name: string;
50
+ readonly scan: ScanSpec;
51
+ /** Remediation text, printed once after the violations. */
52
+ readonly hint?: string;
53
+ /** Security guards fail on every finding not frozen in the per-repo security baseline — no skip flag. */
54
+ readonly security?: boolean;
55
+ run(files: readonly SourceFile[]): GuardOutcome;
56
+ };
57
+
58
+ export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
59
+ return guard.security === true;
60
+ }
61
+
62
+ export function buildSharedProject(
63
+ guards: readonly AstGuard[],
64
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
65
+ ): Project {
66
+ const tsConfigFilePath = frameworkTsConfigPath();
67
+ const project =
68
+ tsConfigFilePath !== undefined
69
+ ? new Project({
70
+ tsConfigFilePath,
71
+ skipAddingFilesFromTsConfig: true,
72
+ skipFileDependencyResolution: true,
73
+ })
74
+ : new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
75
+ // A single guard's undecidable scan must not crash the whole shared
76
+ // project (this runs outside runGuards' per-guard try/catch) — swallow
77
+ // here so runGuards' own scan(guard, roots) call fails just that guard.
78
+ const union = [
79
+ ...new Set(
80
+ guards.flatMap((guard) => {
81
+ try {
82
+ return scanFiles(guard.scan, roots);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }),
87
+ ),
88
+ ];
89
+ // Exact paths, never re-glob: filenames like `[id].tsx` are literal here.
90
+ for (const path of union) project.addSourceFileAtPath(path);
91
+ return project;
92
+ }
93
+
94
+ export function filesForGuard(
95
+ project: Project,
96
+ guard: AstGuard,
97
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
98
+ ): SourceFile[] {
99
+ return scanFiles(guard.scan, roots).map(
100
+ (path) => project.getSourceFile(path) ?? project.addSourceFileAtPath(path),
101
+ );
102
+ }
103
+
104
+ export function relFromRepoRoot(
105
+ filePath: string,
106
+ roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
107
+ ): string {
108
+ const matched = findRepoRootFor(filePath, roots);
109
+ if (matched) {
110
+ return pathRelative(matched.absPath, filePath);
111
+ }
112
+ const marker = filePath.includes("packages/")
113
+ ? "packages/"
114
+ : filePath.includes("samples/")
115
+ ? "samples/"
116
+ : undefined;
117
+ if (marker === undefined) {
118
+ throw new Error(
119
+ `relFromRepoRoot: cannot classify path (no repo root / packages|samples marker): ${filePath}`,
120
+ );
121
+ }
122
+ return filePath.slice(filePath.indexOf(marker));
123
+ }
124
+
125
+ export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): boolean {
126
+ return allowlist.some((re) => re.test(relPath));
127
+ }
128
+
129
+ type BaselinePayload = {
130
+ readonly format: number;
131
+ readonly generated: string;
132
+ readonly total: number;
133
+ readonly perFile: Record<string, number>;
134
+ };
135
+
136
+ export function baselineRatchet(args: {
137
+ readonly file: string;
138
+ readonly formatVersion: number;
139
+ readonly unit: string;
140
+ }): {
141
+ write(current: Readonly<Record<string, number>>): void;
142
+ check(
143
+ current: Readonly<Record<string, number>>,
144
+ remediation: string,
145
+ opts?: {
146
+ readonly formatDriftRemediation?: string;
147
+ readonly resolveLine?: (file: string) => number;
148
+ },
149
+ ): GuardViolation[];
150
+ handleCli(current: Readonly<Record<string, number>>): boolean;
151
+ } {
152
+ const write = (current: Readonly<Record<string, number>>): void => {
153
+ const perFile = Object.fromEntries(
154
+ Object.entries(current).sort(([a], [b]) => a.localeCompare(b)),
155
+ );
156
+ const total = Object.values(perFile).reduce((sum, count) => sum + count, 0);
157
+ const payload: BaselinePayload = {
158
+ format: args.formatVersion,
159
+ generated: new Date().toISOString().slice(0, 10),
160
+ total,
161
+ perFile,
162
+ };
163
+ writeFileSync(args.file, `${JSON.stringify(payload, null, 2)}\n`);
164
+ console.log(` Baseline geschrieben: ${args.file} (total ${total})`);
165
+ };
166
+ const check = (
167
+ current: Readonly<Record<string, number>>,
168
+ remediation: string,
169
+ opts?: {
170
+ readonly formatDriftRemediation?: string;
171
+ readonly resolveLine?: (file: string) => number;
172
+ },
173
+ ): GuardViolation[] => {
174
+ if (!existsSync(args.file)) {
175
+ console.log(
176
+ ` Keine Baseline gefunden (${args.file}). Erst mit \`--write-baseline\` einfrieren — bis dahin Warnung, kein Fail.`,
177
+ );
178
+ return [];
179
+ }
180
+ let raw: Partial<BaselinePayload>;
181
+ try {
182
+ raw = JSON.parse(readFileSync(args.file, "utf-8"));
183
+ } catch (e) {
184
+ return [
185
+ {
186
+ file: args.file,
187
+ line: 1,
188
+ message: `Baseline-Datei nicht lesbar (kaputtes JSON, Merge-Marker, abgebrochener Write): ${e instanceof Error ? e.message : String(e)}. ${opts?.formatDriftRemediation ?? remediation}`,
189
+ },
190
+ ];
191
+ }
192
+ if (
193
+ raw.format !== args.formatVersion ||
194
+ typeof raw.perFile !== "object" ||
195
+ raw.perFile === null
196
+ ) {
197
+ return [
198
+ {
199
+ file: args.file,
200
+ line: 1,
201
+ message:
202
+ raw.format !== args.formatVersion
203
+ ? `Baseline-Format-Drift: erwartet format=${args.formatVersion}, gelesen format=${raw.format ?? "<missing>"}. ${opts?.formatDriftRemediation ?? remediation}`
204
+ : `Baseline-Datei hat kein gültiges "perFile"-Objekt. ${opts?.formatDriftRemediation ?? remediation}`,
205
+ },
206
+ ];
207
+ }
208
+ const baseline = raw as BaselinePayload;
209
+ const { regressions, reduced } = compareToBaseline(current, baseline.perFile);
210
+ if (regressions.length === 0) {
211
+ const total = Object.values(current).reduce((sum, count) => sum + count, 0);
212
+ console.log(` ✓ Baseline (${baseline.total}) — aktuell ${total}`);
213
+ if (reduced > 0) console.log(` ✓ ${reduced} ${args.unit} reduziert seit Baseline.`);
214
+ return [];
215
+ }
216
+ return regressions.map((regression) => ({
217
+ file: regression.file,
218
+ line: opts?.resolveLine?.(regression.file) ?? 1,
219
+ message: `${args.unit} über Baseline: baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline}). ${remediation}`,
220
+ }));
221
+ };
222
+ const handleCli = (current: Readonly<Record<string, number>>): boolean => {
223
+ const cliArgs = process.argv.slice(2);
224
+ if (cliArgs.includes("--write-baseline")) {
225
+ write(current);
226
+ return true;
227
+ }
228
+ if (cliArgs.includes("--no-baseline")) {
229
+ console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
230
+ return true;
231
+ }
232
+ return false;
233
+ };
234
+ return { write, check, handleCli };
235
+ }
236
+
237
+ export type RunResult = {
238
+ readonly name: string;
239
+ readonly ok: boolean;
240
+ readonly ms: number;
241
+ readonly outcome?: GuardOutcome;
242
+ readonly hint?: string;
243
+ readonly error?: string;
244
+ /** Non-exception failure, printed verbatim. */
245
+ readonly message?: string;
246
+ /** No root resolved at all: the target repos are not in this checkout. */
247
+ readonly notApplicable?: boolean;
248
+ /** Files the scan matched — the runner's own count, basis of the floor. */
249
+ readonly matchedFiles?: number;
250
+ /** Roots whose declared sourceRoots hold no .ts/.tsx at all (D4, infra#427). */
251
+ readonly violatingRoots?: readonly string[];
252
+ /** Violations excused by the per-repo security baseline — security guards only. */
253
+ readonly frozenFindings?: number;
254
+ /** True when `isSecurityGuard(guard)` — governs reportResults' security-only lines. */
255
+ readonly security?: boolean;
256
+ /** Printed, never blocking. */
257
+ readonly warnings?: readonly GuardViolation[];
258
+ };
259
+
260
+ // Per-root floor: a root whose declared sourceRoots hold zero .ts/.tsx files is a violation; only applies to scope "source".
261
+ export function checkRootFloor(
262
+ guard: Pick<AstGuard, "scan">,
263
+ scans: readonly RootScan[],
264
+ ): { readonly violatingRoots: readonly string[] } {
265
+ if (guard.scan.scope !== "source") return { violatingRoots: [] };
266
+ return {
267
+ violatingRoots: scans.filter((scan) => scan.sourceSurface === 0).map((scan) => scan.root.name),
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Repo-resolving collaborators, injectable for tests. Without them the
273
+ * notApplicable/normal verdicts can only be reproduced by having (or not
274
+ * having) a real local checkout, so the very branch that decides whether a
275
+ * guard is allowed to report "no violations" is the one no test can reach.
276
+ */
277
+ export type RunGuardsDeps = {
278
+ readonly scan?: (guard: AstGuard, roots: readonly RepoRoot[]) => readonly RootScan[];
279
+ readonly roots?: readonly RepoRoot[];
280
+ /** Defaults to `loadSecurityBaseline` reading the repo's own baseline file. */
281
+ readonly securityBaseline?: (repo: string) => SecurityBaselineLoad;
282
+ /** Also fails on baseline headroom (opt-in — a consumer repo's committed baseline can't be rewritten by its own CI). */
283
+ readonly strictSecurityBaseline?: boolean;
284
+ };
285
+
286
+ // `project` is injectable so the run/catch wiring can be unit-tested with an
287
+ // in-memory project — buildSharedProject() needs the framework tsconfig, absent
288
+ // in a standalone repo checkout.
289
+ export function runGuards(
290
+ guards: readonly AstGuard[],
291
+ project: Project = buildSharedProject(guards),
292
+ deps: RunGuardsDeps = {},
293
+ ): RunResult[] {
294
+ const results: RunResult[] = [];
295
+ const scan =
296
+ deps.scan ?? ((guard: AstGuard, roots: readonly RepoRoot[]) => scanRoots(guard.scan, roots));
297
+ const roots = deps.roots ?? resolveRepoRoots();
298
+
299
+ for (const guard of guards) {
300
+ const start = performance.now();
301
+ try {
302
+ const scans = scan(guard, roots);
303
+ const paths = [...new Set(scans.flatMap((s) => s.files))].sort();
304
+ const files = paths.map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p));
305
+ const outcome = guard.run(files);
306
+ // Security guards get no skip flag: every finding must clear the baseline.
307
+ const securityResult = isSecurityGuard(guard)
308
+ ? applySecurityBaseline({
309
+ guardName: guard.name,
310
+ violations: outcome.violations,
311
+ roots,
312
+ cwd: process.cwd(),
313
+ load:
314
+ deps.securityBaseline ??
315
+ ((repo) => {
316
+ const root = roots.find((r) => r.name === repo);
317
+ return loadSecurityBaseline(repo, root?.absPath ?? process.cwd());
318
+ }),
319
+ strict: deps.strictSecurityBaseline === true,
320
+ })
321
+ : undefined;
322
+ const effectiveOutcome: GuardOutcome = securityResult
323
+ ? { violations: securityResult.blocking }
324
+ : outcome;
325
+ const { violatingRoots } = checkRootFloor(guard, scans);
326
+ results.push({
327
+ name: guard.name,
328
+ ok: effectiveOutcome.violations.length === 0 && violatingRoots.length === 0,
329
+ ms: Math.round(performance.now() - start),
330
+ outcome: effectiveOutcome,
331
+ hint: guard.hint,
332
+ notApplicable: scans.length === 0,
333
+ matchedFiles: files.length,
334
+ violatingRoots: violatingRoots.length > 0 ? violatingRoots : undefined,
335
+ frozenFindings: securityResult?.frozen,
336
+ security: securityResult !== undefined ? true : undefined,
337
+ });
338
+ } catch (e) {
339
+ results.push({
340
+ name: guard.name,
341
+ ok: false,
342
+ ms: Math.round(performance.now() - start),
343
+ error: e instanceof Error ? (e.stack ?? e.message) : String(e),
344
+ });
345
+ }
346
+ }
347
+ return results;
348
+ }
349
+
350
+ export type RepoCheckOutcome = {
351
+ readonly violations: readonly GuardViolation[];
352
+ readonly warnings?: readonly GuardViolation[];
353
+ readonly matchedFiles: number;
354
+ /** Target not in this repo (e.g. no packages/renderer/src). */
355
+ readonly notApplicable: boolean;
356
+ };
357
+
358
+ export type RepoCheck = {
359
+ readonly name: string;
360
+ readonly hint?: string;
361
+ run(roots: readonly RepoRoot[]): RepoCheckOutcome | Promise<RepoCheckOutcome>;
362
+ };
363
+
364
+ const VACUOUS_MESSAGE =
365
+ "0 Dateien gescannt, obwohl die Ziel-Repos im Checkout liegen — die Globs greifen nicht.";
366
+
367
+ /** Standalone-`main()` guards (their own scan/walk, no shared ts-morph project) run in-process through this. */
368
+ export async function runRepoChecks(
369
+ checks: readonly RepoCheck[],
370
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
371
+ ): Promise<RunResult[]> {
372
+ const results: RunResult[] = [];
373
+ for (const check of checks) {
374
+ const start = performance.now();
375
+ try {
376
+ const outcome = await check.run(roots);
377
+ const vacuous = !outcome.notApplicable && outcome.matchedFiles === 0;
378
+ results.push({
379
+ name: check.name,
380
+ ok: outcome.violations.length === 0 && !vacuous,
381
+ ms: Math.round(performance.now() - start),
382
+ outcome: { violations: outcome.violations },
383
+ warnings: outcome.warnings,
384
+ hint: check.hint,
385
+ notApplicable: outcome.notApplicable,
386
+ matchedFiles: outcome.matchedFiles,
387
+ message: vacuous ? VACUOUS_MESSAGE : undefined,
388
+ });
389
+ } catch (e) {
390
+ results.push({
391
+ name: check.name,
392
+ ok: false,
393
+ ms: Math.round(performance.now() - start),
394
+ error: e instanceof Error ? (e.stack ?? e.message) : String(e),
395
+ });
396
+ }
397
+ }
398
+ return results;
399
+ }
400
+
401
+ export type ExplainGuardsDeps = {
402
+ readonly scan?: (guard: AstGuard, roots: readonly RepoRoot[]) => readonly RootScan[];
403
+ readonly resolution?: RootResolution;
404
+ };
405
+
406
+ function scanSpecSummary(spec: ScanSpec): string {
407
+ const parts = [`scope=${spec.scope}`, `ext=${spec.extensions.join(",")}`];
408
+ if (spec.kinds) parts.push(`kinds=${spec.kinds.join(",")}`);
409
+ if (spec.scope === "source" && spec.within) parts.push(`within=${spec.within.join(",")}`);
410
+ if (spec.frameworkWithin) parts.push(`frameworkWithin=${spec.frameworkWithin.join(",")}`);
411
+ return parts.join(" ");
412
+ }
413
+
414
+ /**
415
+ * Diagnosis for `run-guards.ts --explain`: which repo was resolved, and per
416
+ * guard the scan spec and the file count the guard would actually receive —
417
+ * "scans the wrong folder" becomes visible in one call.
418
+ */
419
+ export function explainGuards(
420
+ guards: readonly AstGuard[],
421
+ project: Project,
422
+ deps: ExplainGuardsDeps = {},
423
+ ): string[] {
424
+ const scan =
425
+ deps.scan ?? ((guard: AstGuard, roots: readonly RepoRoot[]) => scanRoots(guard.scan, roots));
426
+ const { roots } = deps.resolution ?? explainRepoRoots();
427
+ const lines = [
428
+ `Repo: ${roots[0]?.root.absPath ?? "— none found (no package.json with kumiko.json/src layout above cwd)"}`,
429
+ ];
430
+ const plainRoots = roots.map((r) => r.root);
431
+ for (const guard of guards) {
432
+ lines.push("", `${guard.name} — ${scanSpecSummary(guard.scan)}`);
433
+ const scans = scan(guard, plainRoots);
434
+ const scanByAbsPath = new Map(scans.map((s) => [s.root.absPath, s]));
435
+ for (const { root, source } of roots) {
436
+ const rootScan = scanByAbsPath.get(root.absPath);
437
+ if (!rootScan) {
438
+ lines.push(` ${root.name} [${source}] — außerhalb kinds`);
439
+ continue;
440
+ }
441
+ // Exact-path lookup, never a re-glob: `project.getSourceFiles(globs)`
442
+ // treats each array entry as a glob pattern, and a literal filename like
443
+ // `[id].tsx` is then a broken (or pathologically slow) character class.
444
+ const fileCount = rootScan.files.filter((f) => project.getSourceFile(f) !== undefined).length;
445
+ lines.push(
446
+ ` ${root.name} [${source}] ${fileCount} Dateien (Source-Surface ${rootScan.sourceSurface})`,
447
+ );
448
+ }
449
+ }
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * Vacuity floor for guards with their own `main()`.
455
+ *
456
+ * `runGuards` only protects the AstGuard form. The standalone guards all end
457
+ * with the same pattern — `if (findings.length === 0) { ok; exit 0 }` —
458
+ * without ever asking whether any files arrived at all. A broken glob reads
459
+ * like a clean run there.
460
+ *
461
+ * Call this right after collecting, before evaluating. Exits the process
462
+ * when nothing was checked — the guard must not then claim "no violations".
463
+ */
464
+ export function classifyRun(args: {
465
+ readonly globCount: number;
466
+ readonly matchedFiles: number;
467
+ /**
468
+ * Does a file exist anywhere under the guard's roots that would satisfy the
469
+ * guard's own presence check? Only relevant when matchedFiles===0. Missing
470
+ * keeps the conservative default of `true` (vacuous whenever matchedFiles is 0).
471
+ */
472
+ readonly filesExistOnDisk?: boolean;
473
+ }): { readonly notApplicable: boolean; readonly vacuous: boolean } {
474
+ const notApplicable = args.globCount === 0;
475
+ const vacuous = !notApplicable && args.matchedFiles === 0 && (args.filesExistOnDisk ?? true);
476
+ return { notApplicable, vacuous };
477
+ }
478
+
479
+ export function reportResults(results: readonly RunResult[]): number {
480
+ let failed = 0;
481
+ for (const r of results) {
482
+ if (r.ok) {
483
+ // Not applicable does not mean checked — that must stay visible,
484
+ // otherwise a standalone run reads like a complete one.
485
+ const scope = r.notApplicable
486
+ ? " — übersprungen, Ziel-Repos nicht im Checkout"
487
+ : ` (${r.matchedFiles ?? 0} Dateien)`;
488
+ const frozen =
489
+ r.frozenFindings && r.frozenFindings > 0
490
+ ? ` — ${r.frozenFindings} eingefrorene Security-Findings (Baseline)`
491
+ : "";
492
+ console.log(` ✓ ${r.name} (${r.ms}ms)${scope}${frozen}`);
493
+ for (const w of r.warnings ?? []) {
494
+ console.log(` ! ${w.file}:${w.line} ${w.message}`);
495
+ }
496
+ continue;
497
+ }
498
+ failed++;
499
+ console.log(` ✗ ${r.name} (${r.ms}ms)`);
500
+ if (r.error) {
501
+ console.error(` THREW: ${r.error}`);
502
+ continue;
503
+ }
504
+ if (r.message) {
505
+ console.error(` ${r.message}`);
506
+ continue;
507
+ }
508
+ if (r.violatingRoots && r.violatingRoots.length > 0) {
509
+ console.error(
510
+ ` 0 Quelldateien in: ${r.violatingRoots.join(", ")} — kumiko.json deklariert sourceRoots, der Scope "source" liefert dort nichts.`,
511
+ );
512
+ console.error(
513
+ " Manifest (kumiko.json) oder Checkout prüfen; ohne Manifest gilt der abgeleitete Fallback packages/*/src bzw. src/.",
514
+ );
515
+ }
516
+ if (r.security) {
517
+ console.error(` [security] Funde ohne Baseline-Deckung sind immer FAIL.`);
518
+ }
519
+ for (const v of r.outcome?.violations ?? []) {
520
+ console.error(` ${v.file}:${v.line} ${v.message}`);
521
+ }
522
+ for (const w of r.warnings ?? []) {
523
+ console.error(` ! ${w.file}:${w.line} ${w.message}`);
524
+ }
525
+ if (r.hint) console.error(` → ${r.hint}`);
526
+ }
527
+ return failed;
528
+ }
529
+
530
+ /** Standalone path: `bun <guard>.ts` builds a single-guard project. */
531
+ export function runStandalone(guard: AstGuard): never {
532
+ const failed = reportResults(runGuards([guard]));
533
+ process.exit(failed > 0 ? 1 : 0);
534
+ }
@@ -0,0 +1,29 @@
1
+ import { type Node, SyntaxKind } from "ts-morph";
2
+
3
+ export const camelize = (s: string): string => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
4
+ export const kebabize = (s: string): string =>
5
+ s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
6
+
7
+ // A handler name appears in tests in several shapes: the raw `name` value,
8
+ // the segment after the last colon ("user:create" → "create", referenced as
9
+ // `UserHandlers.create`), plus its camel/kebab variants ("invite-create" →
10
+ // "inviteCreate"). A test covers a handler if it contains ANY form.
11
+ export function nameForms(name: string): string[] {
12
+ const seg = name.includes(":") ? (name.split(":").pop() ?? name) : name;
13
+ return [...new Set([name, seg, camelize(seg), kebabize(seg)])];
14
+ }
15
+
16
+ export function literalStringOf(prop: Node | undefined): string | undefined {
17
+ if (!prop || prop.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
18
+ const init = prop.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
19
+ if (init?.getKind() !== SyntaxKind.StringLiteral) return undefined;
20
+ return init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralValue();
21
+ }
22
+
23
+ export function escapeRegExp(s: string): string {
24
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ }
26
+
27
+ export function mentionsAsWord(text: string, form: string): boolean {
28
+ return new RegExp(`\\b${escapeRegExp(form)}\\b`).test(text);
29
+ }
@@ -0,0 +1,24 @@
1
+ import { type Node, SyntaxKind } from "ts-morph";
2
+
3
+ function lineHasTag(node: Node, line: number, tag: string): boolean {
4
+ const lines = node.getSourceFile().getFullText().split("\n");
5
+ return (lines[line - 1] ?? "").includes(tag) || (lines[line - 2] ?? "").includes(tag);
6
+ }
7
+
8
+ /** Inline allowlist convention: `// kumiko-lint-ignore <slug> <reason>` on the
9
+ * violation's own line or the line above. For JSX attributes (style=,
10
+ * className= in multi-line tags) the opening element's line also counts —
11
+ * a comment between JSX attributes is not syntactically possible, so the
12
+ * tag then sits above the `<Element`. */
13
+ export function hasIgnoreTag(node: Node, tag: string): boolean {
14
+ if (lineHasTag(node, node.getStartLineNumber(), tag)) return true;
15
+ if (node.getKind() === SyntaxKind.JsxAttribute) {
16
+ const opening =
17
+ node.getFirstAncestorByKind(SyntaxKind.JsxOpeningElement) ??
18
+ node.getFirstAncestorByKind(SyntaxKind.JsxSelfClosingElement);
19
+ if (opening !== undefined && lineHasTag(node, opening.getStartLineNumber(), tag)) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
@@ -0,0 +1,19 @@
1
+ import type { SourceFile } from "ts-morph";
2
+
3
+ const RENDERER_WEB_MODULE = "@cosmicdrift/kumiko-renderer-web";
4
+
5
+ // Shared by every "raw HTML tag instead of framework primitive" guard
6
+ // (guard-no-custom-primitives, guard-raw-interactive-elements): proves the
7
+ // primitive set was reachable in this file — either an import from
8
+ // @cosmicdrift/kumiko-renderer-web (any symbol), or a `usePrimitives` named
9
+ // import, which lives in @cosmicdrift/kumiko-renderer (headless layer), not
10
+ // only in -web (see kumiko-enterprise/packages/ai-agent/src/web/turn-cards.tsx).
11
+ export function hasPrimitivesAccess(sf: SourceFile): boolean {
12
+ for (const imp of sf.getImportDeclarations()) {
13
+ if (imp.getModuleSpecifierValue() === RENDERER_WEB_MODULE) return true;
14
+ if (imp.getNamedImports().some((named) => named.getName() === "usePrimitives")) {
15
+ return true;
16
+ }
17
+ }
18
+ return false;
19
+ }