@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.
- package/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- 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
|
+
}
|