@cosmicdrift/kumiko-guards 0.374.0 → 0.378.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/README.md +6 -4
- package/hooks/pre-push +26 -12
- package/package.json +23 -4
- package/src/_lib/agent-docs.ts +2 -2
- package/src/_lib/primitives-access.ts +9 -5
- package/src/_lib/roots.ts +3 -3
- package/src/_lib/scan-scope.ts +1 -1
- package/src/changes.json +62 -0
- package/src/guard-agent-docs-drift.ts +1 -1
- package/src/guard-api-object-form.ts +42 -28
- package/src/guard-comment-lang.ts +30 -1
- package/src/guard-direct-entity-writes.ts +37 -5
- package/src/guard-direct-fetch.ts +2 -2
- package/src/guard-escape-hatch-declared.ts +14 -0
- package/src/guard-html-escape.ts +2 -2
- package/src/guard-i18n-keys.ts +63 -4
- package/src/guard-no-custom-primitives.ts +2 -2
- package/src/guard-no-direct-fs.ts +0 -1
- package/src/guard-pre-es-patterns.ts +4 -0
- package/src/guard-primitives-discipline.ts +6 -9
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +4 -4
- package/src/guard-renderer-boundaries.ts +35 -9
- package/src/guard-section-fields-raw.ts +1 -1
- package/src/guard-tailwind-scan-surface.ts +54 -20
- package/src/guard-unsafe-append-event.ts +125 -0
- package/src/pre-push.sh +5 -10
- package/src/repo-manifest/app-ports/index.ts +13 -0
- package/src/repo-manifest/app-ports/port-registry.ts +45 -0
- package/src/repo-manifest/app-ports/static-app-config.ts +121 -0
- package/src/repo-manifest/index.ts +262 -0
- package/src/run-guards.ts +2 -0
package/src/guard-i18n-keys.ts
CHANGED
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type Node,
|
|
16
|
+
type ObjectLiteralExpression,
|
|
17
|
+
Project,
|
|
18
|
+
type SourceFile,
|
|
19
|
+
SyntaxKind,
|
|
20
|
+
} from "ts-morph";
|
|
15
21
|
import { type AstGuard, type GuardOutcome, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
16
22
|
|
|
17
23
|
const ROOT = process.cwd();
|
|
@@ -50,11 +56,11 @@ function isI18nBundleFile(filePath: string): boolean {
|
|
|
50
56
|
return (
|
|
51
57
|
/\/i18n\//.test(filePath) ||
|
|
52
58
|
// Flat single-file bundle (src/i18n.ts), same status as src/i18n/index.ts.
|
|
53
|
-
/\/i18n\.ts$/.test(filePath) ||
|
|
59
|
+
/\/i18n\.(ts|js)$/.test(filePath) ||
|
|
54
60
|
/\/features\/[^/]+\/i18n\./.test(filePath) ||
|
|
55
61
|
// Covers any depth below bundled-features/src/<feature>/
|
|
56
62
|
// (e.g. also .../schema/i18n.ts), not just exactly one level.
|
|
57
|
-
/bundled-features\/src\/.*\/i18n\.ts$/.test(filePath)
|
|
63
|
+
/bundled-features\/(src|dist)\/.*\/i18n\.(ts|js)$/.test(filePath)
|
|
58
64
|
);
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -370,7 +376,54 @@ export function resolveExpectedLocales(root: string = ROOT): Set<string> {
|
|
|
370
376
|
return new Set(["de", "en"]);
|
|
371
377
|
}
|
|
372
378
|
|
|
379
|
+
// Published packages ship `dist` (compiled JS) only; workspace links also carry `src`.
|
|
380
|
+
const INSTALLED_FRAMEWORK_SOURCES = [
|
|
381
|
+
"kumiko-*/{src,dist}/**/*.{ts,tsx,js}",
|
|
382
|
+
"!kumiko-*/**/__tests__/**",
|
|
383
|
+
"!kumiko-*/**/*.test.{ts,tsx,js}",
|
|
384
|
+
"!kumiko-*/**/*.d.ts",
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
/** Every `node_modules/@cosmicdrift` from the app root upwards: Node resolves per package, so an app's own
|
|
388
|
+
* scope dir (a few linked packages) must not hide the hoisted one of the bun workspace above it. */
|
|
389
|
+
function findInstalledScopeDirs(root: string): string[] {
|
|
390
|
+
const dirs: string[] = [];
|
|
391
|
+
let curr = path.resolve(root);
|
|
392
|
+
for (;;) {
|
|
393
|
+
const candidate = path.join(curr, "node_modules/@cosmicdrift");
|
|
394
|
+
if (existsSync(candidate)) dirs.push(candidate);
|
|
395
|
+
const parent = path.dirname(curr);
|
|
396
|
+
if (parent === curr) return dirs;
|
|
397
|
+
curr = parent;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Keys the installed framework packages define, so an app may use them without redefining. */
|
|
402
|
+
export function collectInstalledFrameworkKeys(root: string = ROOT): Set<string> {
|
|
403
|
+
if (isFrameworkMonorepo(root)) return new Set();
|
|
404
|
+
// allowJs: definition lookup through imports must follow into the compiled .js files.
|
|
405
|
+
const project = new Project({
|
|
406
|
+
skipAddingFilesFromTsConfig: true,
|
|
407
|
+
compilerOptions: { allowJs: true },
|
|
408
|
+
});
|
|
409
|
+
for (const scopeDir of findInstalledScopeDirs(root)) {
|
|
410
|
+
project.addSourceFilesAtPaths(
|
|
411
|
+
INSTALLED_FRAMEWORK_SOURCES.map((glob) =>
|
|
412
|
+
glob.startsWith("!") ? `!${path.join(scopeDir, glob.slice(1))}` : path.join(scopeDir, glob),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const keys = new Set<string>();
|
|
417
|
+
for (const sf of project.getSourceFiles()) {
|
|
418
|
+
for (const entry of collectInlineTranslationsDefinedKeys(sf)) keys.add(entry.fullKey);
|
|
419
|
+
for (const entry of collectBundleDefinedKeys(sf)) keys.add(entry.fullKey);
|
|
420
|
+
}
|
|
421
|
+
return keys;
|
|
422
|
+
}
|
|
423
|
+
|
|
373
424
|
export type I18nKeysRunOptions = {
|
|
425
|
+
/** Inject the framework keys of the installed packages — tests must not scan node_modules. */
|
|
426
|
+
readonly installedFrameworkKeys?: () => ReadonlySet<string>;
|
|
374
427
|
/** Inject expected locales per owning root — tests must not rely on process.cwd(). */
|
|
375
428
|
readonly expectedLocalesForRoot?: (root: string) => Set<string>;
|
|
376
429
|
};
|
|
@@ -391,7 +444,13 @@ export function checkI18nKeys(
|
|
|
391
444
|
}
|
|
392
445
|
|
|
393
446
|
const definedSet = new Set(definedKeys.map((d) => d.fullKey));
|
|
394
|
-
const
|
|
447
|
+
const undefinedInApp = usedKeys.filter((u) => !definedSet.has(u.key));
|
|
448
|
+
// Scanning node_modules is slow; only pay for it when a key is unresolved.
|
|
449
|
+
const installedKeys =
|
|
450
|
+
undefinedInApp.length === 0
|
|
451
|
+
? new Set<string>()
|
|
452
|
+
: (options.installedFrameworkKeys ?? collectInstalledFrameworkKeys)();
|
|
453
|
+
const missing = undefinedInApp.filter((u) => !installedKeys.has(u.key));
|
|
395
454
|
const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
|
|
396
455
|
const expectedByRoot = new Map<string, Set<string>>();
|
|
397
456
|
const expectedForFile = (rel: string): Set<string> => {
|
|
@@ -89,7 +89,7 @@ const rawFormHtmlBaseline = baselineRatchet({
|
|
|
89
89
|
unit: "raw form HTML finding(s)",
|
|
90
90
|
});
|
|
91
91
|
const RAW_FORM_HTML_REMEDIATION =
|
|
92
|
-
"Use a framework widget (@cosmicdrift/kumiko-renderer
|
|
92
|
+
"Use a framework widget (@cosmicdrift/kumiko-renderer/web: Field/Input, ComboboxInput, …) " +
|
|
93
93
|
`or usePrimitives(). Real exception: // ${IGNORE_TAG} <reason>`;
|
|
94
94
|
|
|
95
95
|
// The rule is new: measured backlog is 0 in every locally checked repo
|
|
@@ -166,7 +166,7 @@ export const guard: AstGuard = {
|
|
|
166
166
|
name: "No-Custom-Primitives Guard (App-Repos)",
|
|
167
167
|
scan: SCAN,
|
|
168
168
|
hint:
|
|
169
|
-
"Use a framework widget (@cosmicdrift/kumiko-renderer
|
|
169
|
+
"Use a framework widget (@cosmicdrift/kumiko-renderer/web: StatCard, SectionCard, StatusBadge, QueryTable, Charts, …) " +
|
|
170
170
|
`or usePrimitives(). Real domain component without a framework equivalent: // ${IGNORE_TAG} <reason>`,
|
|
171
171
|
run: (files: readonly SourceFile[]) => analyse(files, true),
|
|
172
172
|
};
|
|
@@ -83,7 +83,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
83
83
|
|
|
84
84
|
// Guard runner + repo-manifest loader: read the scanned repo tree at dev/CI time, no request input as path.
|
|
85
85
|
{ repo: "kumiko-framework", pattern: /^packages\/guards\/src\// },
|
|
86
|
-
{ repo: "kumiko-framework", pattern: /^packages\/repo-manifest\/src\// },
|
|
87
86
|
|
|
88
87
|
// CLI (@cosmicdrift/kumiko-cli): local dev/CI tooling reading the repo tree, no request input as path.
|
|
89
88
|
{ repo: "kumiko-framework", pattern: /^packages\/cli\/src\// },
|
|
@@ -68,6 +68,8 @@ type Pattern = {
|
|
|
68
68
|
// identifier in the AST (not in strings, not in comments).
|
|
69
69
|
function identifierHits(sf: SourceFile, name: string): Array<{ line: number; snippet: string }> {
|
|
70
70
|
const out: Array<{ line: number; snippet: string }> = [];
|
|
71
|
+
// Cheap text prefilter: a full descendant walk over every file costs seconds.
|
|
72
|
+
if (!sf.getFullText().includes(name)) return out;
|
|
71
73
|
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
72
74
|
if (id.getText() !== name) continue;
|
|
73
75
|
out.push({
|
|
@@ -85,6 +87,7 @@ function stringLiteralHits(
|
|
|
85
87
|
literal: string,
|
|
86
88
|
): Array<{ line: number; snippet: string }> {
|
|
87
89
|
const out: Array<{ line: number; snippet: string }> = [];
|
|
90
|
+
if (!sf.getFullText().includes(literal)) return out;
|
|
88
91
|
for (const lit of sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
|
89
92
|
if (lit.getLiteralText() !== literal) continue;
|
|
90
93
|
out.push({
|
|
@@ -129,6 +132,7 @@ const PATTERNS: readonly Pattern[] = [
|
|
|
129
132
|
// Match `.postEvent(` property accesses to distinguish from the word
|
|
130
133
|
// "postEvent" in prose. Identifier scan would miss method invocations.
|
|
131
134
|
const out: Array<{ line: number; snippet: string }> = [];
|
|
135
|
+
if (!sf.getFullText().includes("postEvent")) return out;
|
|
132
136
|
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
133
137
|
if (pa.getName() !== "postEvent") continue;
|
|
134
138
|
// Only flag when invoked as a method — a property read named
|
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
* Guard: enforces that app-/feature-web code uses UI building blocks from
|
|
4
4
|
* the framework primitives, not hand-rolled Tailwind on raw HTML tags.
|
|
5
5
|
*
|
|
6
|
-
* Why: hand-rolled Tailwind in `web/` folders costs twice:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `<table>`, `<form>`, `<button>`. Direct DOM tags bind the code to
|
|
11
|
-
* web. Framework primitives (`<DataTable>`, `<Form>`, `<Button>`) are
|
|
12
|
-
* contractually platform-neutral.
|
|
6
|
+
* Why: hand-rolled Tailwind in `web/` folders costs twice: every
|
|
7
|
+
* renderer/theme update then has to be re-applied to each hand-tailwound
|
|
8
|
+
* screen individually (style drift), and direct DOM tags bypass the
|
|
9
|
+
* primitives contract (`<DataTable>`, `<Form>`, `<Button>`).
|
|
13
10
|
*
|
|
14
11
|
* Forbidden in app-/feature-web code:
|
|
15
12
|
*
|
|
@@ -20,7 +17,7 @@
|
|
|
20
17
|
* <select> → <ComboboxInput>
|
|
21
18
|
* <textarea> → <Input kind="textarea">
|
|
22
19
|
* <dialog> → <DefaultDialog>
|
|
23
|
-
* alert() / window.alert() → <DefaultDialog> from @cosmicdrift/kumiko-renderer
|
|
20
|
+
* alert() / window.alert() → <DefaultDialog> from @cosmicdrift/kumiko-renderer/web
|
|
24
21
|
* className "bg-card" (hand-rolled card) → <Card> (slots/options)
|
|
25
22
|
* className "mx-auto" + "max-w-*" (hand-rolled screen container)
|
|
26
23
|
* → <PageSection maxWidth> (3xl/4xl/full)
|
|
@@ -63,7 +60,7 @@ const FORBIDDEN_TAGS: ReadonlyArray<{
|
|
|
63
60
|
const FORBIDDEN_CALLS: ReadonlyArray<{
|
|
64
61
|
readonly call: string;
|
|
65
62
|
readonly counterpart: string;
|
|
66
|
-
}> = [{ call: "alert", counterpart: "<DefaultDialog> from @cosmicdrift/kumiko-renderer
|
|
63
|
+
}> = [{ call: "alert", counterpart: "<DefaultDialog> from @cosmicdrift/kumiko-renderer/web" }];
|
|
67
64
|
|
|
68
65
|
// Forbidden className tokens: an allowed tag (`<div>`), but the class gives
|
|
69
66
|
// away hand-rolled primitive chrome. `bg-card` is the dedicated card-surface
|
|
@@ -90,7 +90,7 @@ export const guard: AstGuard = {
|
|
|
90
90
|
name: "Raw-ClassName Guard (App-Repos)",
|
|
91
91
|
scan: SCAN,
|
|
92
92
|
hint:
|
|
93
|
-
"Design-bearing classes belong in widgets/theme tokens (@cosmicdrift/kumiko-renderer
|
|
93
|
+
"Design-bearing classes belong in widgets/theme tokens (@cosmicdrift/kumiko-renderer/web widgets/, --color-status-*). " +
|
|
94
94
|
`Justified exception: // ${IGNORE_TAG} <reason>`,
|
|
95
95
|
run(files: readonly SourceFile[]) {
|
|
96
96
|
const violations: GuardViolation[] = [];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Guard: raw interactive HTML elements in feature UI. <a>, <details> and
|
|
4
4
|
* <summary> each have a framework replacement (usePrimitives().Link,
|
|
5
|
-
* CollapsibleSection from @cosmicdrift/kumiko-renderer
|
|
5
|
+
* CollapsibleSection from @cosmicdrift/kumiko-renderer/web) but nothing was
|
|
6
6
|
* checking for them — turn-cards.tsx/agent-layer.tsx (raw <details>/<summary>)
|
|
7
7
|
* and agent-settings.tsx (raw <a href>) shipped past CI in kumiko-enterprise.
|
|
8
8
|
*
|
|
@@ -56,8 +56,8 @@ type BannedTag = "a" | "details" | "summary";
|
|
|
56
56
|
|
|
57
57
|
const REPLACEMENT: Readonly<Record<BannedTag, string>> = {
|
|
58
58
|
a: "usePrimitives().Link",
|
|
59
|
-
details: "CollapsibleSection (@cosmicdrift/kumiko-renderer
|
|
60
|
-
summary: "CollapsibleSection (@cosmicdrift/kumiko-renderer
|
|
59
|
+
details: "CollapsibleSection (@cosmicdrift/kumiko-renderer/web)",
|
|
60
|
+
summary: "CollapsibleSection (@cosmicdrift/kumiko-renderer/web)",
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
function isBannedTag(tag: string): tag is BannedTag {
|
|
@@ -110,7 +110,7 @@ const createBaseline = (baselineRoot: string) =>
|
|
|
110
110
|
|
|
111
111
|
const REMEDIATION =
|
|
112
112
|
"Framework replacement: <a> → usePrimitives().Link, <details>/<summary> → CollapsibleSection " +
|
|
113
|
-
`(@cosmicdrift/kumiko-renderer
|
|
113
|
+
`(@cosmicdrift/kumiko-renderer/web). Genuine exception: // ${IGNORE_TAG} <reason>`;
|
|
114
114
|
|
|
115
115
|
function analyse(
|
|
116
116
|
files: readonly SourceFile[],
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* Guard: prevents `@cosmicdrift/kumiko-renderer` (shared, platform-neutral)
|
|
4
4
|
* from touching DOM- or platform-specific APIs. As soon as window/document/
|
|
5
5
|
* EventSource/react-dom show up, the separation is broken and the code
|
|
6
|
-
* belongs in
|
|
6
|
+
* belongs in `src/web` instead.
|
|
7
|
+
*
|
|
8
|
+
* `src/web/**` is the browser layer and not scanned at all: it may import
|
|
9
|
+
* everything. `src/headless/**`, `src/flow-diagram/**` and `src/live/**` only get
|
|
10
|
+
* the import rules: the apex page builder emits HTML strings, the flow diagram
|
|
11
|
+
* emits SVG strings and the live dispatcher is a fetch client, so the symbol and JSX-tag rules would be noise there. They stay free
|
|
12
|
+
* of react-dom, jsdom and any import of `src/web`.
|
|
7
13
|
*
|
|
8
14
|
* Deliberately simple: a regex scan over the source text. False positives
|
|
9
15
|
* (e.g. "window" in a string literal) are possible but rare — the guard is a
|
|
10
16
|
* maintenance alarm, not a semantic proof.
|
|
11
17
|
*
|
|
12
18
|
* Checked:
|
|
13
|
-
* - Imports: react-dom/*, jsdom,
|
|
19
|
+
* - Imports: react-dom/*, jsdom, `src/web` (relative or as
|
|
20
|
+
* @cosmicdrift/kumiko-renderer/web), and the deprecated renderer-web /
|
|
21
|
+
* headless / dispatcher-live shims (they depend on this package)
|
|
14
22
|
* - Symbols: window., document., location., history., localStorage,
|
|
15
23
|
* sessionStorage, navigator., EventSource, fetch(
|
|
16
24
|
* (bare — unqualified), HTMLElement, createRoot
|
|
@@ -26,8 +34,10 @@ import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
|
26
34
|
const FORBIDDEN_IMPORTS = [
|
|
27
35
|
/from ["']react-dom(?:\/.*)?["']/,
|
|
28
36
|
/from ["']jsdom["']/,
|
|
29
|
-
/from ["']@cosmicdrift\/kumiko-renderer-web["']/,
|
|
30
|
-
/from ["']@cosmicdrift\/kumiko-renderer
|
|
37
|
+
/from ["']@cosmicdrift\/kumiko-renderer-web(?:\/.*)?["']/,
|
|
38
|
+
/from ["']@cosmicdrift\/kumiko-renderer\/web(?:\/.*)?["']/,
|
|
39
|
+
/from ["'](?:\.\.?\/)+web(?:\/.*)?["']/,
|
|
40
|
+
/from ["']@cosmicdrift\/kumiko-headless(?:\/.*)?["']/,
|
|
31
41
|
/from ["']@cosmicdrift\/kumiko-dispatcher-live["']/,
|
|
32
42
|
];
|
|
33
43
|
|
|
@@ -59,6 +69,12 @@ const FORBIDDEN_SYMBOLS = [
|
|
|
59
69
|
// after whitespace/newline/>, not after an identifier character).
|
|
60
70
|
const FORBIDDEN_JSX_TAG = /(^|\s|>|\()<([a-z][a-zA-Z0-9-]*)(?=[\s/>])/g;
|
|
61
71
|
|
|
72
|
+
// Subtrees of packages/renderer/src that carry only the import rules.
|
|
73
|
+
const IMPORT_RULES_ONLY_DIRS = ["headless", "flow-diagram", "live"] as const;
|
|
74
|
+
|
|
75
|
+
// The browser layer may import anything, so it is not scanned.
|
|
76
|
+
const UNSCANNED_TOP_LEVEL_DIRS = ["web"] as const;
|
|
77
|
+
|
|
62
78
|
type Violation = {
|
|
63
79
|
readonly file: string;
|
|
64
80
|
readonly line: number;
|
|
@@ -66,13 +82,14 @@ type Violation = {
|
|
|
66
82
|
readonly excerpt: string;
|
|
67
83
|
};
|
|
68
84
|
|
|
69
|
-
function walk(dir: string): string[] {
|
|
85
|
+
function walk(dir: string, skippedDirs: readonly string[] = []): string[] {
|
|
70
86
|
const out: string[] = [];
|
|
71
87
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
72
88
|
const full = path.join(dir, entry.name);
|
|
73
89
|
if (entry.isDirectory()) {
|
|
74
90
|
if (entry.name === "__tests__") continue;
|
|
75
91
|
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
92
|
+
if (skippedDirs.includes(entry.name)) continue;
|
|
76
93
|
out.push(...walk(full));
|
|
77
94
|
continue;
|
|
78
95
|
}
|
|
@@ -83,7 +100,7 @@ function walk(dir: string): string[] {
|
|
|
83
100
|
return out;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
export function findViolations(file: string, root: string): Violation[] {
|
|
103
|
+
export function findViolations(file: string, root: string, importRulesOnly = false): Violation[] {
|
|
87
104
|
const text = fs.readFileSync(file, "utf-8");
|
|
88
105
|
const lines = text.split("\n");
|
|
89
106
|
const violations: Violation[] = [];
|
|
@@ -104,6 +121,7 @@ export function findViolations(file: string, root: string): Violation[] {
|
|
|
104
121
|
});
|
|
105
122
|
}
|
|
106
123
|
}
|
|
124
|
+
if (importRulesOnly) continue;
|
|
107
125
|
for (const pattern of FORBIDDEN_SYMBOLS) {
|
|
108
126
|
if (pattern.test(line)) {
|
|
109
127
|
violations.push({
|
|
@@ -126,9 +144,17 @@ export function findViolations(file: string, root: string): Violation[] {
|
|
|
126
144
|
return violations;
|
|
127
145
|
}
|
|
128
146
|
|
|
147
|
+
function isImportRulesOnly(file: string): boolean {
|
|
148
|
+
return IMPORT_RULES_ONLY_DIRS.some((dir) =>
|
|
149
|
+
file.includes(
|
|
150
|
+
`${path.sep}packages${path.sep}renderer${path.sep}src${path.sep}${dir}${path.sep}`,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
export const check: RepoCheck = {
|
|
130
156
|
name: "Renderer-Boundaries Guard",
|
|
131
|
-
hint: "@cosmicdrift/kumiko-renderer must not use DOM/browser/platform APIs. Platform-specific code belongs in
|
|
157
|
+
hint: "@cosmicdrift/kumiko-renderer must not use DOM/browser/platform APIs. Platform-specific code belongs in packages/renderer/src/web.",
|
|
132
158
|
run(roots) {
|
|
133
159
|
const frameworkRoots = roots.filter((r) => r.kind === "framework");
|
|
134
160
|
const scanDirs = frameworkRoots
|
|
@@ -138,10 +164,10 @@ export const check: RepoCheck = {
|
|
|
138
164
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
139
165
|
}
|
|
140
166
|
const files: string[] = [];
|
|
141
|
-
for (const dir of scanDirs) files.push(...walk(dir));
|
|
167
|
+
for (const dir of scanDirs) files.push(...walk(dir, UNSCANNED_TOP_LEVEL_DIRS));
|
|
142
168
|
const root = frameworkRoots[0]?.absPath ?? process.cwd();
|
|
143
169
|
const violations = files.flatMap((file) =>
|
|
144
|
-
findViolations(file, root).map((v) => ({
|
|
170
|
+
findViolations(file, root, isImportRulesOnly(file)).map((v) => ({
|
|
145
171
|
file: v.file,
|
|
146
172
|
line: v.line,
|
|
147
173
|
message: `[${v.rule}] ${v.excerpt}`,
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Guard: `className` string-literal tokens under kumiko-framework's
|
|
4
4
|
* `packages/bundled-features/src/**` must already be reachable through the
|
|
5
|
-
* real Tailwind `@source` scan surface (`packages/renderer
|
|
6
|
-
* `packages/renderer/src`
|
|
7
|
-
*
|
|
8
|
-
* part of that scan surface (infra#654, follow-up to
|
|
5
|
+
* real Tailwind `@source` scan surface (`packages/renderer/src` +
|
|
6
|
+
* `samples/**\/src`, see `packages/renderer/src/web/styles.css`).
|
|
7
|
+
* bundled-features itself is NOT part of that scan surface (infra#654, follow-up to
|
|
9
8
|
* kumiko-framework#2498/#2496): expanding `@source` there was verified to
|
|
10
9
|
* work in the monorepo build but makes the standalone npm-consumer case
|
|
11
10
|
* (no monorepo-relative scan path — #359) falsely green. A class used only
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
* bun guards/guard-tailwind-scan-surface.ts --write-baseline # (re)write the baseline
|
|
34
33
|
* bun guards/guard-tailwind-scan-surface.ts --no-baseline # skip the comparison
|
|
35
34
|
*/
|
|
36
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
35
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
37
36
|
import * as path from "node:path";
|
|
38
37
|
import { Glob } from "bun";
|
|
39
38
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -64,7 +63,6 @@ const SCAN: ScanSpec = {
|
|
|
64
63
|
kinds: ["framework"],
|
|
65
64
|
frameworkWithin: [
|
|
66
65
|
"packages/bundled-features/src/**/*.tsx",
|
|
67
|
-
"packages/renderer-web/src/**",
|
|
68
66
|
"packages/renderer/src/**",
|
|
69
67
|
"samples/**/src/**",
|
|
70
68
|
],
|
|
@@ -75,7 +73,7 @@ const IGNORE_TAG = "kumiko-lint-ignore tailwind-scan-surface";
|
|
|
75
73
|
// The allow-set mirrors what Tailwind's own scanner sees over the real
|
|
76
74
|
// @source surface: every string/template-literal text in the file, not
|
|
77
75
|
// just JSX className attributes — cn(...) calls, variant maps and shared
|
|
78
|
-
// class constants in renderer
|
|
76
|
+
// class constants in renderer/samples legitimately emit
|
|
79
77
|
// classes too. No EXCLUDE filter here on purpose: the @source globs
|
|
80
78
|
// themselves scan __tests__/*.test.tsx just the same, so excluding them
|
|
81
79
|
// from the allow-set would manufacture false positives.
|
|
@@ -148,7 +146,7 @@ function scan(
|
|
|
148
146
|
if (token.length === 0 || allowed.has(token)) continue;
|
|
149
147
|
findings.push({ file, line, token });
|
|
150
148
|
console.warn(
|
|
151
|
-
` [tailwind-scan-surface WARN] ${file}:${line} class "${token}" is outside the Tailwind scan surface (renderer
|
|
149
|
+
` [tailwind-scan-surface WARN] ${file}:${line} class "${token}" is outside the Tailwind scan surface (renderer/src, samples/**/src)`,
|
|
152
150
|
);
|
|
153
151
|
}
|
|
154
152
|
}
|
|
@@ -173,8 +171,8 @@ const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
|
|
|
173
171
|
// Second, independent rule: a consuming app repo (studio,
|
|
174
172
|
// publicstatus, …) imports Framework AND Enterprise packages by npm name
|
|
175
173
|
// (@cosmicdrift/* resp. @cosmicdriftgamestudio/*). Every one of them that
|
|
176
|
-
// ships .tsx with `className` under its own scan surface (renderer
|
|
177
|
-
//
|
|
174
|
+
// ships .tsx with `className` under its own scan surface (renderer/src +
|
|
175
|
+
// samples/**/src) needs its own `@source` entry in the app's
|
|
178
176
|
// styles.css — nothing else scans it. Missed cases so far: kumiko-designer
|
|
179
177
|
// in studio (studio#289), kumiko-ai-agent in publicstatus (publicstatus#442)
|
|
180
178
|
// — both fixed by hand, both silent (no build/lint error) until noticed
|
|
@@ -244,11 +242,26 @@ function classNameExampleIn(pkgDir: string): string | undefined {
|
|
|
244
242
|
}
|
|
245
243
|
|
|
246
244
|
// Resolves `@import "spec";` specifiers in a CSS file to the imported file's
|
|
247
|
-
// absolute path — a bare/package specifier (`@cosmicdrift/kumiko-renderer
|
|
245
|
+
// absolute path — a bare/package specifier (`@cosmicdrift/kumiko-renderer/
|
|
248
246
|
// web/styles.css`) resolves through the SAME node_modules hop search as a
|
|
249
247
|
// package dependency (it IS one); a relative specifier (`./x.css`) resolves
|
|
250
248
|
// against the importing file's own directory. Unresolvable imports (e.g.
|
|
251
249
|
// Tailwind's own `"tailwindcss"`) are dropped, not thrown on.
|
|
250
|
+
// A subpath like "@scope/name/web/styles.css" only exists as a file through
|
|
251
|
+
// the package's `exports` map ("./web/styles.css" -> "./src/web/styles.css").
|
|
252
|
+
function resolveThroughPackageExports(appRoot: string, spec: string): string | undefined {
|
|
253
|
+
const [scope, name, ...rest] = spec.split("/");
|
|
254
|
+
if (scope === undefined || name === undefined || rest.length === 0) return undefined;
|
|
255
|
+
const pkgDir = resolvePackageDir(appRoot, `${scope}/${name}`);
|
|
256
|
+
if (pkgDir === undefined) return undefined;
|
|
257
|
+
const exportsField = readJsonSafe(path.join(pkgDir, "package.json"))?.["exports"];
|
|
258
|
+
if (typeof exportsField !== "object" || exportsField === null) return undefined;
|
|
259
|
+
const target = Object.entries(exportsField).find(([key]) => key === `./${rest.join("/")}`)?.[1];
|
|
260
|
+
if (typeof target !== "string") return undefined;
|
|
261
|
+
const abs = path.join(pkgDir, target);
|
|
262
|
+
return existsSync(abs) ? abs : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
252
265
|
function resolveCssImports(cssAbsPath: string, css: string, appRoot: string): string[] {
|
|
253
266
|
const specifiers = [...css.matchAll(/@import\s+["']([^"']+)["']/g)].map((m) => m[1]);
|
|
254
267
|
const resolved: string[] = [];
|
|
@@ -262,7 +275,8 @@ function resolveCssImports(cssAbsPath: string, css: string, appRoot: string): st
|
|
|
262
275
|
// Package-style specifier including its subpath (e.g. "@scope/name/
|
|
263
276
|
// styles.css") — resolvePackageDir's node_modules hop search works
|
|
264
277
|
// unchanged since it only checks existence, file or directory alike.
|
|
265
|
-
const candidate =
|
|
278
|
+
const candidate =
|
|
279
|
+
resolvePackageDir(appRoot, spec) ?? resolveThroughPackageExports(appRoot, spec);
|
|
266
280
|
if (candidate !== undefined) resolved.push(candidate);
|
|
267
281
|
}
|
|
268
282
|
return resolved;
|
|
@@ -297,8 +311,22 @@ function realpathOrSelf(p: string): string {
|
|
|
297
311
|
}
|
|
298
312
|
}
|
|
299
313
|
|
|
314
|
+
// The deprecated renderer-web stylesheet only `@import`s the renderer's, so the
|
|
315
|
+
// chain has to be followed past the first hop.
|
|
316
|
+
function resolveCssImportsTransitively(cssAbsPath: string, css: string, appRoot: string): string[] {
|
|
317
|
+
const seen = new Set<string>();
|
|
318
|
+
const queue = resolveCssImports(cssAbsPath, css, appRoot);
|
|
319
|
+
for (let imported = queue.shift(); imported !== undefined; imported = queue.shift()) {
|
|
320
|
+
if (seen.has(imported)) continue;
|
|
321
|
+
seen.add(imported);
|
|
322
|
+
if (!existsSync(imported) || statSync(imported).isDirectory()) continue;
|
|
323
|
+
queue.push(...resolveCssImports(imported, readFileSync(imported, "utf-8"), appRoot));
|
|
324
|
+
}
|
|
325
|
+
return [...seen];
|
|
326
|
+
}
|
|
327
|
+
|
|
300
328
|
// A package is covered if an `@source` entry — in the app's own styles.css
|
|
301
|
-
// OR in a CSS file it `@import`s (e.g. renderer
|
|
329
|
+
// OR in a CSS file it `@import`s (e.g. the renderer's web stylesheet, which ships its own
|
|
302
330
|
// @source lines for the framework's scan surface) — resolves, in THIS
|
|
303
331
|
// install layout, to a directory on the same branch as the package's own
|
|
304
332
|
// directory (either one nested inside the other: the entry could scan the
|
|
@@ -314,7 +342,7 @@ function isCoveredByImportChain(
|
|
|
314
342
|
pkgDir: string,
|
|
315
343
|
): boolean {
|
|
316
344
|
const realPkgDir = realpathOrSelf(pkgDir);
|
|
317
|
-
for (const imported of
|
|
345
|
+
for (const imported of resolveCssImportsTransitively(stylesAbs, css, appRoot)) {
|
|
318
346
|
for (const staticDir of sourceStaticDirsFromCss(imported)) {
|
|
319
347
|
const realStaticDir = realpathOrSelf(staticDir);
|
|
320
348
|
if (
|
|
@@ -329,6 +357,12 @@ function isCoveredByImportChain(
|
|
|
329
357
|
return false;
|
|
330
358
|
}
|
|
331
359
|
|
|
360
|
+
function isRendererWebStylesheetImport(css: string): boolean {
|
|
361
|
+
return (
|
|
362
|
+
css.includes("kumiko-renderer/web/styles.css") || css.includes("kumiko-renderer-web/styles.css")
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
332
366
|
export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFinding[] {
|
|
333
367
|
const findings: SourceCoverageFinding[] = [];
|
|
334
368
|
for (const root of roots) {
|
|
@@ -336,14 +370,14 @@ export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFi
|
|
|
336
370
|
const stylesAbs = path.join(root.absPath, APP_STYLES_REL);
|
|
337
371
|
if (!existsSync(stylesAbs)) continue;
|
|
338
372
|
const css = readFileSync(stylesAbs, "utf-8");
|
|
339
|
-
// Only apps on the renderer
|
|
373
|
+
// Only apps on the renderer web @source convention are in scope — an
|
|
340
374
|
// app without that @import never had a scan surface to extend.
|
|
341
|
-
if (!
|
|
375
|
+
if (!isRendererWebStylesheetImport(css)) continue;
|
|
342
376
|
const pkg = readJsonSafe(path.join(root.absPath, "package.json"));
|
|
343
377
|
if (!pkg) continue;
|
|
344
378
|
const file = path.relative(ROOT, stylesAbs);
|
|
345
379
|
for (const name of dependencyNames(pkg)) {
|
|
346
|
-
if (css.includes(name)) continue; // already @source'd (or @import'd, e.g. renderer
|
|
380
|
+
if (css.includes(name)) continue; // already @source'd (or @import'd, e.g. the renderer's web stylesheet itself)
|
|
347
381
|
const dir = resolvePackageDir(root.absPath, name);
|
|
348
382
|
if (dir === undefined) continue;
|
|
349
383
|
const example = classNameExampleIn(dir);
|
|
@@ -531,7 +565,7 @@ function checkBaseline(findings: readonly Finding[], baselineRoot: string): Guar
|
|
|
531
565
|
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
532
566
|
return tailwindScanSurfaceBaseline(baselineRoot).check(
|
|
533
567
|
baselineCounts(findings),
|
|
534
|
-
"Class outside the @source scan surface (renderer
|
|
568
|
+
"Class outside the @source scan surface (renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer/src/web.",
|
|
535
569
|
{
|
|
536
570
|
formatDriftRemediation:
|
|
537
571
|
"Run `bun guards/guard-tailwind-scan-surface.ts --write-baseline` once.",
|
|
@@ -569,8 +603,8 @@ export function createGuard(options: { readonly baselineRoot?: string } = {}): A
|
|
|
569
603
|
name: "Tailwind-Scan-Surface Guard",
|
|
570
604
|
scan: SCAN,
|
|
571
605
|
hint:
|
|
572
|
-
"Tailwind class in bundled-features outside the @source scan surface (renderer
|
|
573
|
-
`— reuse a class already emitted there, or move the styling into renderer
|
|
606
|
+
"Tailwind class in bundled-features outside the @source scan surface (renderer/src, samples/**/src) " +
|
|
607
|
+
`— reuse a class already emitted there, or move the styling into renderer/src/web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
|
|
574
608
|
"Package without @source coverage in an app: add an @source entry for both install layouts. " +
|
|
575
609
|
"@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
|
|
576
610
|
run: (files, roots = resolveRepoRoots()) => analyse(files, true, roots, baselineRoot),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: reports `ctx.unsafeAppendEvent` (also `ctx["unsafeAppendEvent"]` and destructuring) outside `migrations/`, `upcasters/` and tests.
|
|
4
|
+
*
|
|
5
|
+
* `appendEvent` is the typed default, `tryAppendEvent` the optimistic variant. The untyped
|
|
6
|
+
* `unsafeAppendEvent` skips payload typing and is meant for migrations and upcasters, where the
|
|
7
|
+
* event type is not knowable at compile time.
|
|
8
|
+
*
|
|
9
|
+
* Existing call sites are frozen per file in `.kumiko-unsafe-append-event-baseline.json`; a count
|
|
10
|
+
* over the baseline fails, at or under passes. Without the file (consumer repos) the guard only
|
|
11
|
+
* warns.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* bun packages/guards/src/guard-unsafe-append-event.ts # compare to baseline
|
|
15
|
+
* bun packages/guards/src/guard-unsafe-append-event.ts --write-baseline # freeze current
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
|
+
import {
|
|
22
|
+
ALL_REPO_KINDS,
|
|
23
|
+
type AstGuard,
|
|
24
|
+
baselineRatchet,
|
|
25
|
+
buildSharedProject,
|
|
26
|
+
filesForGuard,
|
|
27
|
+
isLocalFinding,
|
|
28
|
+
relFromRepoRoot,
|
|
29
|
+
runStandalone,
|
|
30
|
+
type ScanSpec,
|
|
31
|
+
} from "./_lib/guard-kit";
|
|
32
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
33
|
+
|
|
34
|
+
const SCAN: ScanSpec = {
|
|
35
|
+
kinds: ALL_REPO_KINDS,
|
|
36
|
+
scope: "source",
|
|
37
|
+
extensions: ["ts"],
|
|
38
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SYMBOL = "unsafeAppendEvent";
|
|
42
|
+
const ALLOWED_PATH = /(^|\/)(migrations|upcasters|__tests__)\/|\.test\.ts$|\.d\.ts$/;
|
|
43
|
+
|
|
44
|
+
function unsafeAppendEventLines(sf: SourceFile): number[] {
|
|
45
|
+
const propertyAccesses = sf
|
|
46
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
47
|
+
.filter((access) => access.getNameNode().getText() === SYMBOL);
|
|
48
|
+
const elementAccesses = sf
|
|
49
|
+
.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)
|
|
50
|
+
.filter((access) => {
|
|
51
|
+
const argument = access.getArgumentExpression();
|
|
52
|
+
return (
|
|
53
|
+
argument !== undefined &&
|
|
54
|
+
(Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) &&
|
|
55
|
+
argument.getLiteralText() === SYMBOL
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
const destructurings = sf
|
|
59
|
+
.getDescendantsOfKind(SyntaxKind.BindingElement)
|
|
60
|
+
.filter(
|
|
61
|
+
(element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === SYMBOL,
|
|
62
|
+
);
|
|
63
|
+
return [...propertyAccesses, ...elementAccesses, ...destructurings].map((node) =>
|
|
64
|
+
node.getStartLineNumber(),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Finding = { readonly file: string; readonly line: number };
|
|
69
|
+
|
|
70
|
+
function scan(files: readonly SourceFile[], roots = resolveRepoRoots()): Finding[] {
|
|
71
|
+
const findings: Finding[] = [];
|
|
72
|
+
for (const sf of files) {
|
|
73
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
74
|
+
if (ALLOWED_PATH.test(file)) continue;
|
|
75
|
+
for (const line of unsafeAppendEventLines(sf)) findings.push({ file, line });
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function countsPerFile(findings: readonly Finding[]): Record<string, number> {
|
|
81
|
+
const counts: Record<string, number> = {};
|
|
82
|
+
for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
83
|
+
return counts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const GUARD_NAME = "Unsafe-Append-Event Guard";
|
|
87
|
+
const DEFAULT_BASELINE_FILE = path.join(process.cwd(), ".kumiko-unsafe-append-event-baseline.json");
|
|
88
|
+
|
|
89
|
+
const REMEDIATION =
|
|
90
|
+
"unsafeAppendEvent drops payload typing. Use ctx.appendEvent (default) or ctx.tryAppendEvent (conflict as result); the untyped variant belongs in migrations/ and upcasters/.";
|
|
91
|
+
|
|
92
|
+
const REWRITE_COMMAND = `kumiko-guards guards --write-baseline --guard="${GUARD_NAME}"`;
|
|
93
|
+
|
|
94
|
+
export function createUnsafeAppendEventGuard(baselineFile: string): AstGuard {
|
|
95
|
+
const baseline = baselineRatchet({
|
|
96
|
+
file: baselineFile,
|
|
97
|
+
formatVersion: 1,
|
|
98
|
+
unit: "unsafeAppendEvent call(s)",
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
name: GUARD_NAME,
|
|
102
|
+
scan: SCAN,
|
|
103
|
+
hint: `${REMEDIATION} After a deliberate change: \`${REWRITE_COMMAND}\`.`,
|
|
104
|
+
run(files, roots = resolveRepoRoots()) {
|
|
105
|
+
const findings = scan(files, roots);
|
|
106
|
+
const violations = baseline.check(countsPerFile(findings), REMEDIATION, {
|
|
107
|
+
formatDriftRemediation: `Run \`${REWRITE_COMMAND}\` once.`,
|
|
108
|
+
resolveLine: (file) => findings.find((f) => f.file === file)?.line ?? 1,
|
|
109
|
+
});
|
|
110
|
+
return { violations };
|
|
111
|
+
},
|
|
112
|
+
writeBaseline: (files, roots) => baseline.write(countsPerFile(scan(files, roots))),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const guard = createUnsafeAppendEventGuard(DEFAULT_BASELINE_FILE);
|
|
117
|
+
|
|
118
|
+
if (import.meta.main) {
|
|
119
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
120
|
+
const project = buildSharedProject([guard]);
|
|
121
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
runStandalone(guard);
|
|
125
|
+
}
|