@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,588 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: `className` string-literal tokens under kumiko-framework's
4
+ * `packages/bundled-features/src/**` must already be reachable through the
5
+ * real Tailwind `@source` scan surface (`packages/renderer-web/src` +
6
+ * `packages/renderer/src` + `samples/**\/src`, see
7
+ * `packages/renderer-web/src/styles.css`). bundled-features itself is NOT
8
+ * part of that scan surface (infra#654, follow-up to
9
+ * kumiko-framework#2498/#2496): expanding `@source` there was verified to
10
+ * work in the monorepo build but makes the standalone npm-consumer case
11
+ * (no monorepo-relative scan path — #359) falsely green. A class used only
12
+ * in bundled-features silently drops out of the compiled CSS at runtime,
13
+ * with no build/lint error.
14
+ *
15
+ * V1 scope (per issue decision, deliberately not a Tailwind-CLI CSS
16
+ * regen): only plain string-literal `className="..."` JSX attributes are
17
+ * extracted on the bundled-features side — no `cn(...)`, no template
18
+ * literals, no dynamic class maps. The allow-set (the real scan surface)
19
+ * is read generously instead — every string/template-literal in those
20
+ * files, not just `className` attributes — because Tailwind's own scanner
21
+ * is a text scan, not JSX-attribute-aware; under-approximating the allow
22
+ * side would turn into false positives on a guard that blocks CI. Tokens
23
+ * compare WITH their modifier prefixes intact (`hover:mb-2` is its own
24
+ * Tailwind candidate, distinct from `mb-2`).
25
+ *
26
+ * Baseline-regression guard like guard-pii-annotations.ts: pins the
27
+ * currently-known violation count per file. Reductions are allowed but
28
+ * don't auto-update the baseline. Without a baseline file the guard stays
29
+ * warning-only (bootstrap: run `--write-baseline` once).
30
+ *
31
+ * Usage:
32
+ * bun guards/guard-tailwind-scan-surface.ts # compare against baseline
33
+ * bun guards/guard-tailwind-scan-surface.ts --write-baseline # (re)write the baseline
34
+ * bun guards/guard-tailwind-scan-surface.ts --no-baseline # skip the comparison
35
+ */
36
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
37
+ import * as path from "node:path";
38
+ import { Glob } from "bun";
39
+ import { type SourceFile, SyntaxKind } from "ts-morph";
40
+ import {
41
+ type AstGuard,
42
+ baselineRatchet,
43
+ buildSharedProject,
44
+ filesForGuard,
45
+ type GuardOutcome,
46
+ type GuardViolation,
47
+ relFromRepoRoot,
48
+ runStandalone,
49
+ type ScanSpec,
50
+ } from "./_lib/guard-kit";
51
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
52
+ import { findLocalRepo, isFlatSrcLayout, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
53
+
54
+ const ROOT = process.cwd();
55
+ // Baselines are committed at the repo root, not wherever the guard happens
56
+ // to run from — resolve against the local repo, falling back to cwd only
57
+ // when no repo root could be resolved at all (e.g. a bare in-memory test).
58
+ const BASELINE_ROOT = findLocalRepo()?.absPath ?? ROOT;
59
+
60
+ const TARGET_DIR = "packages/bundled-features/src/";
61
+ const SCAN: ScanSpec = {
62
+ scope: "source",
63
+ extensions: ["ts", "tsx"],
64
+ kinds: ["framework"],
65
+ frameworkWithin: [
66
+ "packages/bundled-features/src/**/*.tsx",
67
+ "packages/renderer-web/src/**",
68
+ "packages/renderer/src/**",
69
+ "samples/**/src/**",
70
+ ],
71
+ };
72
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.tsx?$)/;
73
+ const IGNORE_TAG = "kumiko-lint-ignore tailwind-scan-surface";
74
+
75
+ // The allow-set mirrors what Tailwind's own scanner sees over the real
76
+ // @source surface: every string/template-literal text in the file, not
77
+ // just JSX className attributes — cn(...) calls, variant maps and shared
78
+ // class constants in renderer-web/renderer/samples legitimately emit
79
+ // classes too. No EXCLUDE filter here on purpose: the @source globs
80
+ // themselves scan __tests__/*.test.tsx just the same, so excluding them
81
+ // from the allow-set would manufacture false positives.
82
+ function allStringLikeTokens(sf: SourceFile): Set<string> {
83
+ const tokens = new Set<string>();
84
+ const add = (text: string): void => {
85
+ for (const t of text.split(/\s+/)) if (t.length > 0) tokens.add(t);
86
+ };
87
+ for (const s of sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
88
+ add(s.getLiteralText());
89
+ }
90
+ for (const s of sf.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
91
+ add(s.getLiteralText());
92
+ }
93
+ for (const kind of [
94
+ SyntaxKind.TemplateHead,
95
+ SyntaxKind.TemplateMiddle,
96
+ SyntaxKind.TemplateTail,
97
+ ]) {
98
+ for (const s of sf.getDescendantsOfKind(kind)) {
99
+ add(s.getText().replace(/^[`}]|[`$]{?$/g, ""));
100
+ }
101
+ }
102
+ return tokens;
103
+ }
104
+
105
+ export interface Finding {
106
+ readonly file: string;
107
+ readonly line: number;
108
+ readonly token: string;
109
+ }
110
+
111
+ function isTargetFile(sf: SourceFile): boolean {
112
+ return relFromRepoRoot(sf.getFilePath()).startsWith(TARGET_DIR);
113
+ }
114
+
115
+ function scan(files: readonly SourceFile[]): {
116
+ findings: Finding[];
117
+ scanned: number;
118
+ } {
119
+ const allowed = new Set<string>();
120
+ const targets: SourceFile[] = [];
121
+ let scanned = 0;
122
+
123
+ for (const sf of files) {
124
+ if (isTargetFile(sf)) {
125
+ if (EXCLUDE.test(sf.getFilePath())) continue;
126
+ targets.push(sf);
127
+ scanned++;
128
+ continue;
129
+ }
130
+ for (const t of allStringLikeTokens(sf)) allowed.add(t);
131
+ scanned++;
132
+ }
133
+
134
+ const findings: Finding[] = [];
135
+ for (const sf of targets) {
136
+ const file = path.relative(ROOT, sf.getFilePath());
137
+ for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
138
+ if (attr.getNameNode().getText() !== "className") continue;
139
+ if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
140
+ const init = attr.getInitializer();
141
+ if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
142
+ const text = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
143
+ const line = init.getStartLineNumber();
144
+ for (const token of text.split(/\s+/)) {
145
+ if (token.length === 0 || allowed.has(token)) continue;
146
+ findings.push({ file, line, token });
147
+ console.warn(
148
+ ` [tailwind-scan-surface WARN] ${file}:${line} Klasse "${token}" liegt außerhalb des Tailwind-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src)`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+ return { findings, scanned };
154
+ }
155
+
156
+ function countByFile(findings: readonly Finding[]): Record<string, number> {
157
+ const counts: Record<string, number> = {};
158
+ for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
159
+ return counts;
160
+ }
161
+
162
+ const BASELINE_FILE = ".kumiko-tailwind-scan-surface-baseline.json";
163
+ const tailwindScanSurfaceBaseline = baselineRatchet({
164
+ file: path.join(BASELINE_ROOT, BASELINE_FILE),
165
+ formatVersion: 1,
166
+ unit: "Klassen-Token(s)",
167
+ });
168
+
169
+ // Second, independent rule: a consuming app repo (studio,
170
+ // publicstatus, …) imports Framework AND Enterprise packages by npm name
171
+ // (@cosmicdrift/* resp. @cosmicdriftgamestudio/*). Every one of them that
172
+ // ships .tsx with `className` under its own scan surface (renderer-web +
173
+ // renderer + samples/**/src) needs its own `@source` entry in the app's
174
+ // styles.css — nothing else scans it. Missed cases so far: kumiko-designer
175
+ // in studio (studio#289), kumiko-ai-agent in publicstatus (publicstatus#442)
176
+ // — both fixed by hand, both silent (no build/lint error) until noticed
177
+ // visually. This does not need ts-morph: it reads package.json + styles.css
178
+ // + a plain text scan of the dependency's shipped .tsx files directly off
179
+ // disk, independent of the scan-selected SourceFile list above.
180
+ const APP_STYLES_REL = "src/styles.css";
181
+ const PACKAGE_SCOPE = /^@cosmicdrift(?:gamestudio)?\//;
182
+ const CLASSNAME_ATTR = /\bclassName\s*=/;
183
+ // Hoisting depths a workspace/npm install can put node_modules at, relative
184
+ // to the app root: flat (own node_modules), one level up (bun workspace
185
+ // hoist to the parent), two levels up (worktree parked under .wt/<app>/).
186
+ const NODE_MODULES_HOPS = ["", "..", "../.."];
187
+
188
+ export interface SourceCoverageFinding {
189
+ readonly file: string;
190
+ readonly line: number;
191
+ readonly packageName: string;
192
+ readonly example: string;
193
+ }
194
+
195
+ function readJsonSafe(absPath: string): Record<string, unknown> | undefined {
196
+ if (!existsSync(absPath)) return undefined;
197
+ try {
198
+ return JSON.parse(readFileSync(absPath, "utf-8"));
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
204
+ function dependencyNames(pkg: Record<string, unknown>): string[] {
205
+ const names = new Set<string>();
206
+ for (const field of ["dependencies", "devDependencies"]) {
207
+ const deps = pkg[field];
208
+ if (typeof deps !== "object" || deps === null) continue;
209
+ for (const name of Object.keys(deps)) names.add(name);
210
+ }
211
+ return [...names].filter((name) => PACKAGE_SCOPE.test(name));
212
+ }
213
+
214
+ function resolvePackageDir(appRoot: string, pkgName: string): string | undefined {
215
+ for (const hop of NODE_MODULES_HOPS) {
216
+ const candidate = path.join(appRoot, hop, "node_modules", pkgName);
217
+ if (existsSync(candidate)) return candidate;
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ // Proof the package actually needs a scan entry: at least one shipped .tsx
223
+ // under src/ with a `className=` attribute. A type-only or headless package
224
+ // (no JSX styling) legitimately needs no @source entry.
225
+ function classNameExampleIn(pkgDir: string): string | undefined {
226
+ const srcDir = path.join(pkgDir, "src");
227
+ if (!existsSync(srcDir)) return undefined;
228
+ for (const rel of new Glob("**/*.tsx").scanSync({ cwd: srcDir })) {
229
+ if (/__tests__|\.test\.tsx$/.test(rel)) continue;
230
+ const abs = path.join(srcDir, rel);
231
+ let content: string;
232
+ try {
233
+ content = readFileSync(abs, "utf-8");
234
+ } catch {
235
+ continue;
236
+ }
237
+ if (CLASSNAME_ATTR.test(content)) return path.join("src", rel);
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ // Resolves `@import "spec";` specifiers in a CSS file to the imported file's
243
+ // absolute path — a bare/package specifier (`@cosmicdrift/kumiko-renderer-
244
+ // web/styles.css`) resolves through the SAME node_modules hop search as a
245
+ // package dependency (it IS one); a relative specifier (`./x.css`) resolves
246
+ // against the importing file's own directory. Unresolvable imports (e.g.
247
+ // Tailwind's own `"tailwindcss"`) are dropped, not thrown on.
248
+ function resolveCssImports(cssAbsPath: string, css: string, appRoot: string): string[] {
249
+ const specifiers = [...css.matchAll(/@import\s+["']([^"']+)["']/g)].map((m) => m[1]);
250
+ const resolved: string[] = [];
251
+ for (const spec of specifiers) {
252
+ if (spec === undefined) continue;
253
+ if (spec.startsWith(".")) {
254
+ const candidate = path.resolve(path.dirname(cssAbsPath), spec);
255
+ if (existsSync(candidate)) resolved.push(candidate);
256
+ continue;
257
+ }
258
+ // Package-style specifier including its subpath (e.g. "@scope/name/
259
+ // styles.css") — resolvePackageDir's node_modules hop search works
260
+ // unchanged since it only checks existence, file or directory alike.
261
+ const candidate = resolvePackageDir(appRoot, spec);
262
+ if (candidate !== undefined) resolved.push(candidate);
263
+ }
264
+ return resolved;
265
+ }
266
+
267
+ // The static (non-glob) directory an `@source` entry names, resolved
268
+ // relative to the CSS FILE THAT DECLARES IT (Tailwind's own resolution
269
+ // rule) — not the app's styles.css. A path that doesn't exist in this
270
+ // install layout (e.g. the monorepo-only entry inside a standalone npm
271
+ // consumer) is dropped: it names nothing here, so it covers nothing here.
272
+ function sourceStaticDirsFromCss(cssAbsPath: string): string[] {
273
+ if (!existsSync(cssAbsPath)) return [];
274
+ const css = readFileSync(cssAbsPath, "utf-8");
275
+ const dir = path.dirname(cssAbsPath);
276
+ const dirs: string[] = [];
277
+ for (const m of css.matchAll(/@source\s+["']([^"']+)["']/g)) {
278
+ const raw = m[1];
279
+ if (raw === undefined) continue;
280
+ const starIdx = raw.indexOf("*");
281
+ const staticPart = (starIdx === -1 ? raw : raw.slice(0, starIdx)).replace(/\/$/, "");
282
+ const abs = path.resolve(dir, staticPart || ".");
283
+ if (existsSync(abs)) dirs.push(abs);
284
+ }
285
+ return dirs;
286
+ }
287
+
288
+ function realpathOrSelf(p: string): string {
289
+ try {
290
+ return realpathSync(p);
291
+ } catch {
292
+ return p;
293
+ }
294
+ }
295
+
296
+ // A package is covered if an `@source` entry — in the app's own styles.css
297
+ // OR in a CSS file it `@import`s (e.g. renderer-web's, which ships its own
298
+ // @source lines for the framework's scan surface) — resolves, in THIS
299
+ // install layout, to a directory on the same branch as the package's own
300
+ // directory (either one nested inside the other: the entry could scan the
301
+ // package's full dir or just its `src/` subtree, and a package could in
302
+ // principle sit inside a broader scanned tree). Compared via realpath
303
+ // because node_modules resolution (symlinked workspace package) and an
304
+ // @source path resolved from inside another package's own source tree
305
+ // (monorepo-relative) can reach the same directory through different routes.
306
+ function isCoveredByImportChain(
307
+ stylesAbs: string,
308
+ css: string,
309
+ appRoot: string,
310
+ pkgDir: string,
311
+ ): boolean {
312
+ const realPkgDir = realpathOrSelf(pkgDir);
313
+ for (const imported of resolveCssImports(stylesAbs, css, appRoot)) {
314
+ for (const staticDir of sourceStaticDirsFromCss(imported)) {
315
+ const realStaticDir = realpathOrSelf(staticDir);
316
+ if (
317
+ realPkgDir === realStaticDir ||
318
+ realPkgDir.startsWith(`${realStaticDir}${path.sep}`) ||
319
+ realStaticDir.startsWith(`${realPkgDir}${path.sep}`)
320
+ ) {
321
+ return true;
322
+ }
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+
328
+ export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFinding[] {
329
+ const findings: SourceCoverageFinding[] = [];
330
+ for (const root of roots) {
331
+ if (!isFlatSrcLayout(root)) continue;
332
+ const stylesAbs = path.join(root.absPath, APP_STYLES_REL);
333
+ if (!existsSync(stylesAbs)) continue;
334
+ const css = readFileSync(stylesAbs, "utf-8");
335
+ // Only apps on the renderer-web @source convention are in scope — an
336
+ // app without that @import never had a scan surface to extend.
337
+ if (!css.includes("kumiko-renderer-web/styles.css")) continue;
338
+ const pkg = readJsonSafe(path.join(root.absPath, "package.json"));
339
+ if (!pkg) continue;
340
+ const file = path.relative(ROOT, stylesAbs);
341
+ for (const name of dependencyNames(pkg)) {
342
+ if (css.includes(name)) continue; // already @source'd (or @import'd, e.g. renderer-web itself)
343
+ const dir = resolvePackageDir(root.absPath, name);
344
+ if (dir === undefined) continue;
345
+ const example = classNameExampleIn(dir);
346
+ if (example === undefined) continue;
347
+ if (isCoveredByImportChain(stylesAbs, css, root.absPath, dir)) continue;
348
+ findings.push({ file, line: 1, packageName: name, example });
349
+ }
350
+ }
351
+ return findings;
352
+ }
353
+
354
+ // Third, independent rule: an `@source` glob that is already present and
355
+ // resolves on disk (studio#289's own fix) can still match nothing once the
356
+ // referenced package is installed from a registry instead of a workspace
357
+ // symlink — the symlink exposes the package's full source checkout (`src/`
358
+ // included), a registry tarball only ships what its `package.json` `files`
359
+ // allowlists. This is exactly the bug that got past the guard the first
360
+ // time, so it's a hard violation, not a warning: no baseline/ratchet, every
361
+ // finding fails the run directly (still only under baseline comparison —
362
+ // `--no-baseline` reports it like the other rules but doesn't fail).
363
+ const NODE_MODULES_PKG_SEGMENT = /node_modules\/((?:@[^/]+\/)?[^/]+)\/([^/]+)/;
364
+
365
+ export interface PublishedScanSurfaceFinding {
366
+ readonly file: string;
367
+ readonly line: number;
368
+ readonly glob: string;
369
+ readonly packageName: string;
370
+ readonly segment: string;
371
+ readonly filesField: readonly string[];
372
+ }
373
+
374
+ interface NodeModulesSourceEntry {
375
+ readonly raw: string;
376
+ readonly line: number;
377
+ readonly packageName: string;
378
+ readonly segment: string;
379
+ readonly packageDir: string;
380
+ }
381
+
382
+ // The segment is covered if `files` names it exactly or names a path that
383
+ // starts with it (`"src"` or `"src/web"` both cover the `src` segment) —
384
+ // mirrors how npm itself matches `files` entries against publish candidates.
385
+ function filesFieldCoversSegment(filesField: readonly string[], segment: string): boolean {
386
+ return filesField.some((entry) => {
387
+ const normalized = entry.replace(/^\.\//, "").replace(/\/+$/, "");
388
+ return normalized === segment || normalized.startsWith(`${segment}/`);
389
+ });
390
+ }
391
+
392
+ // Resolved relative to the CSS file's own directory, same rule as
393
+ // sourceStaticDirsFromCss — only entries whose node_modules/<pkg> segment
394
+ // exists in THIS install layout are considered (a dead hop for a different
395
+ // layout names nothing here, same tolerance as the rest of the guard).
396
+ function nodeModulesSourceEntries(cssAbsPath: string, css: string): NodeModulesSourceEntry[] {
397
+ const dir = path.dirname(cssAbsPath);
398
+ const entries: NodeModulesSourceEntry[] = [];
399
+ for (const m of css.matchAll(/@source\s+["']([^"']+)["']/g)) {
400
+ const raw = m[1];
401
+ if (raw === undefined || m.index === undefined) continue;
402
+ const nm = NODE_MODULES_PKG_SEGMENT.exec(raw);
403
+ if (nm === null) continue;
404
+ const packageName = nm[1];
405
+ const segment = nm[2];
406
+ if (packageName === undefined || segment === undefined) continue;
407
+ const nmIndex = raw.indexOf("node_modules/");
408
+ const packagePathPrefix = `${raw.slice(0, nmIndex)}node_modules/${packageName}`;
409
+ const packageDir = path.resolve(dir, packagePathPrefix);
410
+ if (!existsSync(packageDir)) continue;
411
+ const line = css.slice(0, m.index).split("\n").length;
412
+ entries.push({ raw, line, packageName, segment, packageDir });
413
+ }
414
+ return entries;
415
+ }
416
+
417
+ export function scanPublishedScanSurface(
418
+ roots: readonly RepoRoot[],
419
+ ): PublishedScanSurfaceFinding[] {
420
+ const findings: PublishedScanSurfaceFinding[] = [];
421
+ for (const root of roots) {
422
+ if (!isFlatSrcLayout(root)) continue;
423
+ const stylesAbs = path.join(root.absPath, APP_STYLES_REL);
424
+ if (!existsSync(stylesAbs)) continue;
425
+ const css = readFileSync(stylesAbs, "utf-8");
426
+ const file = path.relative(ROOT, stylesAbs);
427
+
428
+ const byPackage = new Map<string, NodeModulesSourceEntry[]>();
429
+ for (const entry of nodeModulesSourceEntries(stylesAbs, css)) {
430
+ const list = byPackage.get(entry.packageName) ?? [];
431
+ list.push(entry);
432
+ byPackage.set(entry.packageName, list);
433
+ }
434
+
435
+ for (const [packageName, entries] of byPackage) {
436
+ const pkg = readJsonSafe(path.join(entries[0]?.packageDir ?? "", "package.json"));
437
+ if (pkg === undefined) continue;
438
+ const filesField = pkg["files"];
439
+ if (!Array.isArray(filesField)) continue;
440
+ const filesList = filesField.filter((f): f is string => typeof f === "string");
441
+ // Legit second glob: another @source of the same package already
442
+ // hits a published path (e.g. dist/**/*.js) — the dead one is only
443
+ // a harmless workspace-flow convenience, not a coverage gap.
444
+ const coveredElsewhere = entries.some((e) => filesFieldCoversSegment(filesList, e.segment));
445
+ if (coveredElsewhere) continue;
446
+ for (const e of entries) {
447
+ findings.push({
448
+ file,
449
+ line: e.line,
450
+ glob: e.raw,
451
+ packageName,
452
+ segment: e.segment,
453
+ filesField: filesList,
454
+ });
455
+ }
456
+ }
457
+ }
458
+ return findings;
459
+ }
460
+
461
+ function formatPublishedScanSurfaceMessage(f: PublishedScanSurfaceFinding): string {
462
+ const filesText = f.filesField.join(", ");
463
+ return `@source "${f.glob}" targets '${f.segment}/' but ${f.packageName} only publishes [${filesText}] — matches nothing in a registry install`;
464
+ }
465
+
466
+ // No baseline: every finding is a violation, unconditionally — see the
467
+ // comment above NODE_MODULES_PKG_SEGMENT for why this one doesn't ratchet.
468
+ function publishedScanSurfaceViolations(
469
+ findings: readonly PublishedScanSurfaceFinding[],
470
+ ): GuardViolation[] {
471
+ return findings.map((f) => ({
472
+ file: f.file,
473
+ line: f.line,
474
+ message: formatPublishedScanSurfaceMessage(f),
475
+ }));
476
+ }
477
+
478
+ function reportPublishedScanSurfaceFindings(
479
+ findings: readonly PublishedScanSurfaceFinding[],
480
+ ): void {
481
+ for (const f of findings) {
482
+ console.warn(
483
+ ` [tailwind-scan-surface WARN] ${f.file}:${f.line} ${formatPublishedScanSurfaceMessage(f)}`,
484
+ );
485
+ }
486
+ }
487
+
488
+ const SOURCE_COVERAGE_BASELINE_FILE = ".kumiko-tailwind-source-coverage-baseline.json";
489
+ const sourceCoverageBaseline = baselineRatchet({
490
+ file: path.join(BASELINE_ROOT, SOURCE_COVERAGE_BASELINE_FILE),
491
+ formatVersion: 1,
492
+ unit: "fehlende(r) @source-Eintrag/Einträge",
493
+ });
494
+
495
+ export function sourceCoverageBaselineCounts(
496
+ findings: readonly SourceCoverageFinding[],
497
+ ): Record<string, number> {
498
+ const counts: Record<string, number> = {};
499
+ for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
500
+ return counts;
501
+ }
502
+
503
+ function checkSourceCoverageBaseline(findings: readonly SourceCoverageFinding[]): GuardViolation[] {
504
+ const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
505
+ return sourceCoverageBaseline.check(
506
+ sourceCoverageBaselineCounts(findings),
507
+ "Paket liefert Tailwind-Klassen ohne @source-Abdeckung in dieser App — @source-Eintrag für beide Install-Layouts ergänzen (Muster: bestehende bundled-features-Einträge).",
508
+ {
509
+ formatDriftRemediation:
510
+ "Einmalig `bun guards/guard-tailwind-scan-surface.ts --write-baseline` aufrufen.",
511
+ resolveLine,
512
+ },
513
+ );
514
+ }
515
+
516
+ // One place for "what goes into the baseline", used by both the compare and
517
+ // the write path.
518
+ export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
519
+ return countByFile(findings);
520
+ }
521
+
522
+ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
523
+ const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
524
+ return tailwindScanSurfaceBaseline.check(
525
+ baselineCounts(findings),
526
+ "Klasse außerhalb des @source-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src) — eine dort bereits emittierte Klasse wiederverwenden, oder das Styling nach renderer-web ziehen.",
527
+ {
528
+ formatDriftRemediation:
529
+ "Einmalig `bun guards/guard-tailwind-scan-surface.ts --write-baseline` aufrufen.",
530
+ resolveLine,
531
+ },
532
+ );
533
+ }
534
+
535
+ export function analyse(
536
+ files: readonly SourceFile[],
537
+ compareBaseline: boolean,
538
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
539
+ ): GuardOutcome {
540
+ const { findings } = scan(files);
541
+ const coverageFindings = scanSourceCoverage(roots);
542
+ const publishedSurfaceFindings = scanPublishedScanSurface(roots);
543
+ reportPublishedScanSurfaceFindings(publishedSurfaceFindings);
544
+ if (!compareBaseline) {
545
+ console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
546
+ return { violations: [] };
547
+ }
548
+ return {
549
+ violations: [
550
+ ...checkBaseline(findings),
551
+ ...checkSourceCoverageBaseline(coverageFindings),
552
+ ...publishedScanSurfaceViolations(publishedSurfaceFindings),
553
+ ],
554
+ };
555
+ }
556
+
557
+ export const guard: AstGuard = {
558
+ name: "Tailwind-Scan-Surface Guard",
559
+ scan: SCAN,
560
+ hint:
561
+ "Tailwind-Klasse in bundled-features außerhalb des @source-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src) " +
562
+ `— reuse a class already emitted there, or move the styling into renderer-web. Begründete Ausnahme: // ${IGNORE_TAG} <Grund>. ` +
563
+ "Paket ohne @source-Abdeckung in einer App: @source-Eintrag für beide Install-Layouts ergänzen. " +
564
+ "@source mit node_modules/<pkg>/<segment>-Pfad: prüfen ob <pkg> dieses Segment überhaupt publiziert (package.json files).",
565
+ run: (files) => analyse(files, true),
566
+ };
567
+
568
+ // Flags are read ONLY here, not in run() — the shared runner
569
+ // (run-ui-guards.ts) runs every guard with the same argv, a
570
+ // --write-baseline there must not silently rewrite the baseline.
571
+ if (import.meta.main) {
572
+ const args = process.argv.slice(2);
573
+ if (args.includes("--write-baseline")) {
574
+ const project = buildSharedProject([guard]);
575
+ const { findings } = scan(filesForGuard(project, guard));
576
+ tailwindScanSurfaceBaseline.write(baselineCounts(findings));
577
+ sourceCoverageBaseline.write(
578
+ sourceCoverageBaselineCounts(scanSourceCoverage(resolveRepoRoots())),
579
+ );
580
+ process.exit(0);
581
+ }
582
+ if (args.includes("--no-baseline")) {
583
+ const project = buildSharedProject([guard]);
584
+ analyse(filesForGuard(project, guard), false);
585
+ process.exit(0);
586
+ }
587
+ runStandalone(guard);
588
+ }