@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,304 @@
1
+ /**
2
+ * Single-repo resolution for guards.
3
+ *
4
+ * Unlike the infra multi-repo guard runner (which scans a declared set of
5
+ * sibling checkouts), this public package scans exactly ONE repo: the one
6
+ * `cwd` sits in. `resolveRepoRoots()` returns either an empty array (no
7
+ * package.json with a kumiko.json/src layout above `cwd`) or a single-element
8
+ * array for the local repo.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import {
14
+ type LoadedRepoManifest,
15
+ loadRepoManifest,
16
+ REPO_MANIFEST_FILE,
17
+ type RepoManifest,
18
+ RepoManifestError,
19
+ type RepoManifestSource,
20
+ } from "@cosmicdrift/kumiko-repo-manifest";
21
+
22
+ export type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
23
+
24
+ import type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
25
+
26
+ export type RepoRoot = {
27
+ /** Repo identifier — the package.json `name`. */
28
+ readonly name: string;
29
+ /** Absolute path to the repo root. */
30
+ readonly absPath: string;
31
+ /** Manifest role (framework|library|app) — same as manifest.kind. */
32
+ readonly kind: RepoKind;
33
+ readonly manifest: RepoManifest;
34
+ readonly manifestSource: RepoManifestSource;
35
+ };
36
+
37
+ const warnedDerivedFallbacks = new Set<string>();
38
+
39
+ function warnDerivedFallbackOnce(message: string): void {
40
+ if (warnedDerivedFallbacks.has(message)) return;
41
+ warnedDerivedFallbacks.add(message);
42
+ console.error(message);
43
+ }
44
+
45
+ const manifestCache = new Map<string, LoadedRepoManifest | undefined>();
46
+
47
+ /**
48
+ * Loads (or derives) the repo manifest at `dir`, undefined when `dir` is not
49
+ * a scan root at all — no kumiko.json AND no derivable packages/*\/src or src/
50
+ * layout. A present-but-invalid kumiko.json still throws (fail loud); only the
51
+ * "nothing to derive from" case is swallowed here.
52
+ */
53
+ function manifestAt(dir: string): LoadedRepoManifest | undefined {
54
+ const abs = resolve(dir);
55
+ if (manifestCache.has(abs)) return manifestCache.get(abs);
56
+ const fileExists = existsSync(join(abs, REPO_MANIFEST_FILE));
57
+ let result: LoadedRepoManifest | undefined;
58
+ if (fileExists) {
59
+ result = loadRepoManifest(abs, { warn: warnDerivedFallbackOnce });
60
+ } else {
61
+ try {
62
+ result = loadRepoManifest(abs, { warn: warnDerivedFallbackOnce });
63
+ } catch (e) {
64
+ if (!(e instanceof RepoManifestError)) throw e;
65
+ result = undefined;
66
+ }
67
+ }
68
+ manifestCache.set(abs, result);
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Spawned git calls get an allowlisted environment, never `...process.env`.
74
+ * A caller running as a pre-push hook would otherwise inherit `GIT_DIR`/
75
+ * `GIT_WORK_TREE` from git, which git prefers over `cwd` — so an inherited
76
+ * environment would answer for the hook's repo instead of the path being
77
+ * asked about. `HOME` stays in, so the global config is still read.
78
+ */
79
+ const GIT_ENV_KEYS = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME"] as const;
80
+
81
+ function gitEnv(): Record<string, string> {
82
+ const env: Record<string, string> = {};
83
+ for (const key of GIT_ENV_KEYS) {
84
+ const value = process.env[key];
85
+ if (value !== undefined) env[key] = value;
86
+ }
87
+ return env;
88
+ }
89
+
90
+ function git(from: string, args: readonly string[]): string | undefined {
91
+ if (!existsSync(from)) return undefined;
92
+ try {
93
+ const out = execFileSync("git", [...args], {
94
+ cwd: from,
95
+ env: gitEnv(),
96
+ encoding: "utf-8",
97
+ stdio: ["ignore", "pipe", "ignore"],
98
+ }).trim();
99
+ return out === "" ? undefined : out;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ const toplevelCache = new Map<string, string | undefined>();
106
+
107
+ /**
108
+ * The git toplevel of `cwd`, spelled via `cwd`'s own ancestors: git answers
109
+ * with the realpath (macOS `/private/var/…`), and callers compare against the
110
+ * path they passed in.
111
+ */
112
+ function gitToplevelOf(cwd: string): string | undefined {
113
+ if (toplevelCache.has(cwd)) return toplevelCache.get(cwd);
114
+ const toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
115
+ let spelled: string | undefined;
116
+ if (toplevel !== undefined && existsSync(toplevel)) {
117
+ const target = realpathSync(toplevel);
118
+ spelled = toplevel;
119
+ for (let curr = cwd; ; curr = dirname(curr)) {
120
+ if (existsSync(curr) && realpathSync(curr) === target) {
121
+ spelled = curr;
122
+ break;
123
+ }
124
+ if (dirname(curr) === curr) break;
125
+ }
126
+ }
127
+ toplevelCache.set(cwd, spelled);
128
+ return spelled;
129
+ }
130
+
131
+ function rootFrom(name: string, dir: string, loaded: LoadedRepoManifest): RepoRoot {
132
+ return {
133
+ name,
134
+ absPath: dir,
135
+ kind: loaded.manifest.kind,
136
+ manifest: loaded.manifest,
137
+ manifestSource: loaded.source,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * The manifest for `dir` as a scan root: a `kumiko.json` file always wins; a
143
+ * derived manifest (no file) counts only with a repo marker present, so a
144
+ * nested package without its own repo marker cannot claim to be the scan
145
+ * root.
146
+ */
147
+ function manifestRootAt(dir: string): LoadedRepoManifest | undefined {
148
+ const loaded = manifestAt(dir);
149
+ if (loaded === undefined || loaded.source === "file") return loaded;
150
+ const hasRepoMarker = existsSync(join(dir, ".git")) || existsSync(join(dir, "bun.lock"));
151
+ return hasRepoMarker ? loaded : undefined;
152
+ }
153
+
154
+ /** Classifies `dir` as a repo root by its package.json plus its manifest. */
155
+ function repoAt(dir: string): RepoRoot | undefined {
156
+ const pkgPath = join(dir, "package.json");
157
+ if (!existsSync(pkgPath)) return undefined;
158
+ let pkg: unknown;
159
+ try {
160
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ if (typeof pkg !== "object" || pkg === null || !("name" in pkg)) {
165
+ return undefined;
166
+ }
167
+ const name = pkg.name;
168
+ if (typeof name !== "string") return undefined;
169
+ const loaded = manifestRootAt(dir);
170
+ return loaded ? rootFrom(name, dir, loaded) : undefined;
171
+ }
172
+
173
+ function isDerivedAppFallback(root: RepoRoot): boolean {
174
+ return root.manifestSource === "derived" && root.kind === "app";
175
+ }
176
+
177
+ /**
178
+ * The repository `cwd` belongs to.
179
+ *
180
+ * Git first: `rev-parse --show-toplevel` is the checkout root from any
181
+ * subdirectory, package or worktree, independent of folder names. The
182
+ * package.json walk remains for non-git trees; there a derived-manifest "app"
183
+ * candidate (no kumiko.json, just a bare `src/` layout) is only a fallback, so
184
+ * a repo further up with its own manifest or explicit kind still wins over its
185
+ * nested unregistered packages.
186
+ */
187
+ export function findLocalRepo(cwd: string = process.cwd()): RepoRoot | undefined {
188
+ const start = resolve(cwd);
189
+ const toplevel = gitToplevelOf(start);
190
+ const fromGit = toplevel !== undefined ? repoAt(toplevel) : undefined;
191
+ if (fromGit) return fromGit;
192
+ let fallback: RepoRoot | undefined;
193
+ for (let curr = start; curr !== dirname(curr); curr = dirname(curr)) {
194
+ const candidate = repoAt(curr);
195
+ if (candidate === undefined) continue;
196
+ if (!isDerivedAppFallback(candidate)) return candidate;
197
+ fallback = candidate;
198
+ }
199
+ return fallback;
200
+ }
201
+
202
+ /** Where a scanned root came from — printed by `run-guards.ts --explain`. */
203
+ export type RootSource = "local";
204
+
205
+ export type ExplainedRoot = {
206
+ readonly root: RepoRoot;
207
+ readonly source: RootSource;
208
+ };
209
+
210
+ export type RootResolution = {
211
+ readonly roots: readonly ExplainedRoot[];
212
+ };
213
+
214
+ /**
215
+ * Resolves the repo to scan: empty when `cwd` sits above no repo root at all,
216
+ * otherwise the single local repo.
217
+ */
218
+ export function explainRepoRoots(cwd: string = process.cwd()): RootResolution {
219
+ const local = findLocalRepo(cwd);
220
+ return { roots: local ? [{ root: local, source: "local" }] : [] };
221
+ }
222
+
223
+ export function resolveRepoRoots(cwd: string = process.cwd()): ReadonlyArray<RepoRoot> {
224
+ return explainRepoRoots(cwd).roots.map((r) => r.root);
225
+ }
226
+
227
+ /**
228
+ * tsconfig.json for a framework sub-package (e.g. `bundled-features`,
229
+ * `dev-server`) — ts-morph needs a real tsconfig as a moduleResolution
230
+ * anchor when adding source files. Undefined when the local repo has no
231
+ * usable tsconfig at all (e.g. a bare in-memory test workspace).
232
+ */
233
+ export function frameworkPackageTsConfigPath(
234
+ pkg: string,
235
+ cwd: string = process.cwd(),
236
+ ): string | undefined {
237
+ const local = findLocalRepo(cwd);
238
+ if (!local) return undefined;
239
+ if (local.kind === "framework") {
240
+ const packageTsConfig = resolve(local.absPath, `packages/${pkg}/tsconfig.json`);
241
+ if (existsSync(packageTsConfig)) return packageTsConfig;
242
+ }
243
+ const localTsConfig = join(local.absPath, "tsconfig.json");
244
+ return existsSync(localTsConfig) ? localTsConfig : undefined;
245
+ }
246
+
247
+ export function frameworkTsConfigPath(cwd?: string): string | undefined {
248
+ return frameworkPackageTsConfigPath("framework", cwd);
249
+ }
250
+
251
+ export function isFlatSrcLayout(root: RepoRoot): boolean {
252
+ return root.manifest.sourceRoots.length === 1 && root.manifest.sourceRoots[0] === "src";
253
+ }
254
+
255
+ // Only a whole-segment "*" is supported for dir expansion — "**" or a partial
256
+ // wildcard (e.g. "pkg*") has no single real directory to expand to.
257
+ function assertExpandablePattern(pattern: string): void {
258
+ for (const segment of pattern.split("/")) {
259
+ if (segment === "*") continue;
260
+ if (/[*?[\]{}]/.test(segment)) {
261
+ throw new Error(`sourceRootDirs: unsupported pattern for dir expansion: "${pattern}"`);
262
+ }
263
+ }
264
+ }
265
+
266
+ function isRealDirectory(path: string): boolean {
267
+ try {
268
+ return lstatSync(path).isDirectory();
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ function expandSourceRootPattern(rootAbs: string, pattern: string): string[] {
275
+ assertExpandablePattern(pattern);
276
+ let current = [rootAbs];
277
+ for (const segment of pattern.split("/")) {
278
+ const next: string[] = [];
279
+ for (const dir of current) {
280
+ if (segment === "*") {
281
+ if (!existsSync(dir)) continue;
282
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
283
+ if (entry.isDirectory()) next.push(join(dir, entry.name));
284
+ }
285
+ } else {
286
+ const candidate = join(dir, segment);
287
+ // lstat (not statSync) so a symlinked "src" dir is rejected, not followed.
288
+ if (isRealDirectory(candidate)) next.push(candidate);
289
+ }
290
+ }
291
+ current = next;
292
+ }
293
+ return current;
294
+ }
295
+
296
+ export function sourceRootDirs(root: RepoRoot): string[] {
297
+ const out = new Set<string>();
298
+ for (const pattern of root.manifest.sourceRoots) {
299
+ for (const dir of expandSourceRootPattern(root.absPath, pattern)) {
300
+ out.add(dir);
301
+ }
302
+ }
303
+ return [...out].sort();
304
+ }
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export type TextLineFinding = {
4
+ readonly file: string;
5
+ readonly line: number;
6
+ readonly text: string;
7
+ };
8
+
9
+ /** Collect lines of `abs` for which `predicate` is true. */
10
+ export function scanLinesForPredicate(
11
+ abs: string,
12
+ fileLabel: string,
13
+ predicate: (line: string) => boolean,
14
+ into: TextLineFinding[] = [],
15
+ ): TextLineFinding[] {
16
+ const lines = readFileSync(abs, "utf8").split("\n");
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const line = lines[i];
19
+ if (line === undefined) continue;
20
+ if (predicate(line)) {
21
+ into.push({ file: fileLabel, line: i + 1, text: line.trim() });
22
+ }
23
+ }
24
+ return into;
25
+ }
@@ -0,0 +1,152 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
+ import type { RepoKind } from "@cosmicdrift/kumiko-repo-manifest";
4
+ import { Glob } from "bun";
5
+ import type { RepoRoot } from "./roots";
6
+
7
+ export type ScanScope = "source" | "tests";
8
+ export type ScanExtension = "ts" | "tsx";
9
+
10
+ type ScanSpecBase = {
11
+ readonly extensions: readonly ScanExtension[];
12
+ /** Manifest roles to scan; omitted = every root. */
13
+ readonly kinds?: readonly RepoKind[];
14
+ /** Repo-relative globs; in a kind "framework" root only files matching one of them are kept (replaces `within` there). */
15
+ readonly frameworkWithin?: readonly string[];
16
+ };
17
+
18
+ export type ScanSpec =
19
+ | (ScanSpecBase & {
20
+ readonly scope: "source";
21
+ /** Globs relative to the matched source root dir; narrows only. */
22
+ readonly within?: readonly string[];
23
+ })
24
+ | (ScanSpecBase & { readonly scope: "tests" });
25
+
26
+ export type RootScan = {
27
+ readonly root: RepoRoot;
28
+ /** Absolute paths handed to the guard, sorted. */
29
+ readonly files: readonly string[];
30
+ /** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the D4 floor input. */
31
+ readonly sourceSurface: number;
32
+ };
33
+
34
+ type Hit = {
35
+ readonly repoRel: string;
36
+ /** Set only for source hits — the path relative to the matched sourceRoot. */
37
+ readonly sourceRootRel?: string;
38
+ };
39
+
40
+ // Many guards share the same source/test surface per root — scan each
41
+ // (root, pattern) pair from disk only once per process.
42
+ const globScanCache = new Map<string, readonly string[]>();
43
+
44
+ function scanGlob(rootAbsPath: string, pattern: string): readonly string[] {
45
+ const key = JSON.stringify([rootAbsPath, pattern]);
46
+ const cached = globScanCache.get(key);
47
+ if (cached) return cached;
48
+ const rootReal = realpathSync(rootAbsPath);
49
+ const hits: string[] = [];
50
+ for (const rel of new Glob(pattern).scanSync({
51
+ cwd: rootAbsPath,
52
+ onlyFiles: true,
53
+ followSymlinks: false,
54
+ dot: false,
55
+ })) {
56
+ let real: string;
57
+ try {
58
+ real = realpathSync(join(rootAbsPath, rel));
59
+ } catch {
60
+ continue;
61
+ }
62
+ // A symlinked file pointing outside the repo is never scanned — the same
63
+ // escape a symlinked directory would give if followSymlinks allowed it.
64
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) continue;
65
+ hits.push(rel);
66
+ }
67
+ globScanCache.set(key, hits);
68
+ return hits;
69
+ }
70
+
71
+ function matchesAny(repoRel: string, patterns: readonly string[]): boolean {
72
+ return patterns.some((pattern) => new Glob(pattern).match(repoRel));
73
+ }
74
+
75
+ function sourceRootRelOf(repoRel: string, sourceRoot: string): string | undefined {
76
+ const segments = repoRel.split("/");
77
+ for (let k = 1; k <= segments.length; k++) {
78
+ if (new Glob(sourceRoot).match(segments.slice(0, k).join("/"))) {
79
+ return segments.slice(k).join("/");
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function sourceHits(root: RepoRoot): Hit[] {
86
+ const byRepoRel = new Map<string, Hit>();
87
+ for (const sourceRoot of root.manifest.sourceRoots) {
88
+ for (const repoRel of scanGlob(root.absPath, `${sourceRoot}/**/*.{ts,tsx}`)) {
89
+ // First matching sourceRoot wins for overlapping sourceRoots.
90
+ if (byRepoRel.has(repoRel)) continue;
91
+ byRepoRel.set(repoRel, {
92
+ repoRel,
93
+ sourceRootRel: sourceRootRelOf(repoRel, sourceRoot),
94
+ });
95
+ }
96
+ }
97
+ return [...byRepoRel.values()];
98
+ }
99
+
100
+ function testHits(root: RepoRoot): Hit[] {
101
+ const byRepoRel = new Map<string, Hit>();
102
+ for (const testGlob of root.manifest.testGlobs) {
103
+ for (const repoRel of scanGlob(root.absPath, testGlob)) {
104
+ if (byRepoRel.has(repoRel)) continue;
105
+ byRepoRel.set(repoRel, { repoRel });
106
+ }
107
+ }
108
+ return [...byRepoRel.values()];
109
+ }
110
+
111
+ function afterExcludes(hits: readonly Hit[], excludes: readonly string[] | undefined): Hit[] {
112
+ if (!excludes || excludes.length === 0) return [...hits];
113
+ return hits.filter((hit) => !matchesAny(hit.repoRel, excludes));
114
+ }
115
+
116
+ function hasScanExtension(repoRel: string, extensions: readonly ScanExtension[]): boolean {
117
+ return extensions.some((ext) => repoRel.endsWith(`.${ext}`));
118
+ }
119
+
120
+ function keepForSpec(hit: Hit, root: RepoRoot, spec: ScanSpec): boolean {
121
+ if (!hasScanExtension(hit.repoRel, spec.extensions)) return false;
122
+ if (root.kind === "framework" && spec.frameworkWithin) {
123
+ return matchesAny(hit.repoRel, spec.frameworkWithin);
124
+ }
125
+ if (spec.scope === "source" && spec.within) {
126
+ return hit.sourceRootRel !== undefined && matchesAny(hit.sourceRootRel, spec.within);
127
+ }
128
+ return true;
129
+ }
130
+
131
+ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
132
+ const sourceSurfaceHits = afterExcludes(sourceHits(root), root.manifest.excludes);
133
+ const scopeHits =
134
+ spec.scope === "source"
135
+ ? sourceSurfaceHits
136
+ : afterExcludes(testHits(root), root.manifest.excludes);
137
+ const files = scopeHits
138
+ .filter((hit) => keepForSpec(hit, root, spec))
139
+ .map((hit) => join(root.absPath, hit.repoRel))
140
+ .sort();
141
+ return { root, files, sourceSurface: sourceSurfaceHits.length };
142
+ }
143
+
144
+ export function scanRoots(spec: ScanSpec, roots: readonly RepoRoot[]): RootScan[] {
145
+ return roots
146
+ .filter((root) => !spec.kinds || spec.kinds.includes(root.kind))
147
+ .map((root) => scanRoot(spec, root));
148
+ }
149
+
150
+ export function scanFiles(spec: ScanSpec, roots: readonly RepoRoot[]): string[] {
151
+ return [...new Set(scanRoots(spec, roots).flatMap((rootScan) => rootScan.files))].sort();
152
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `--write-security-baseline`: freezes every security guard finding into a
3
+ * committed per-repo baseline — a missing file must never mean "not scanned".
4
+ */
5
+ import type { Project } from "ts-morph";
6
+ import { type AstGuard, filesForGuard } from "./guard-kit";
7
+ import { resolveRepoRoots } from "./roots";
8
+ import {
9
+ buildSecurityBaseline,
10
+ loadSecurityBaseline,
11
+ locateFinding,
12
+ writeSecurityBaseline,
13
+ } from "./security-baseline";
14
+
15
+ export function writeSecurityBaselines(guards: readonly AstGuard[], project: Project): void {
16
+ const guardViolations = guards.map((guard) => ({
17
+ guardName: guard.name,
18
+ violations: guard.run(filesForGuard(project, guard)).violations,
19
+ }));
20
+
21
+ const roots = resolveRepoRoots();
22
+ const cwd = process.cwd();
23
+
24
+ // Load every repo's existing baseline (and validate it) before writing any
25
+ // file — a hardFail marker for repo N must never be lost to an overwrite
26
+ // that already ran for repos 1..N-1 while repo N's own baseline was broken.
27
+ const hardFailByRepo = new Map<string, readonly string[]>();
28
+ for (const root of roots) {
29
+ const load = loadSecurityBaseline(root.name, root.absPath);
30
+ if (load.kind === "invalid") {
31
+ console.error(` ✗ Security-Baseline ${load.file}: ${load.reason}`);
32
+ process.exit(1);
33
+ }
34
+ hardFailByRepo.set(root.name, load.hardFail);
35
+ }
36
+
37
+ for (const root of roots) {
38
+ const hardFail = hardFailByRepo.get(root.name) ?? [];
39
+ const baseline = buildSecurityBaseline(root.name, guardViolations, roots, cwd, hardFail);
40
+ const path = writeSecurityBaseline(baseline, root.absPath);
41
+ console.log(` Security-Baseline geschrieben: ${path} (total ${baseline.total})`);
42
+ for (const guardName of hardFail) {
43
+ const count = guardViolations
44
+ .filter((gv) => gv.guardName === guardName)
45
+ .flatMap((gv) => gv.violations)
46
+ .filter((v) => locateFinding(v.file, roots, cwd)?.repo === root.name).length;
47
+ if (count > 0) {
48
+ console.warn(
49
+ ` ! ${root.name}: ${count} Funde von ${guardName} nicht eingefroren (hardFail) — der Guard-Lauf blockiert sie`,
50
+ );
51
+ }
52
+ }
53
+ }
54
+ }