@cosmicdrift/kumiko-guards 0.374.0 → 0.377.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 +4 -3
- package/hooks/pre-push +1 -7
- 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 +38 -0
- package/src/guard-agent-docs-drift.ts +1 -1
- package/src/guard-comment-lang.ts +30 -1
- package/src/guard-direct-fetch.ts +2 -2
- 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-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/README.md
CHANGED
|
@@ -68,15 +68,16 @@ A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
|
|
|
68
68
|
shim to `.husky/pre-push`. The shim execs the nearest executable
|
|
69
69
|
`node_modules/.bin/kumiko-pre-push` from the repo root upwards (so worktrees
|
|
70
70
|
without their own `node_modules` resolve via the parent install) and fails
|
|
71
|
-
closed with a `bun install`
|
|
71
|
+
closed with a `bun install` hint when none exists. Inside
|
|
72
72
|
the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
|
|
73
73
|
the pushing repo only; in a standalone clone it runs the repo's own
|
|
74
74
|
`package.json` `test` script. From a `.wt/<name>` worktree under the parent
|
|
75
75
|
workspace it runs a tracked `scripts/check-wt.sh` if present, otherwise the
|
|
76
76
|
worktree's own `package.json` scripts `typecheck`, `lint`, `test`, and
|
|
77
77
|
`test:dom` (whichever are declared), directly in the worktree — the parent's
|
|
78
|
-
`bun check` would otherwise check the main checkout instead.
|
|
79
|
-
`
|
|
78
|
+
`bun check` would otherwise check the main checkout instead. There is no
|
|
79
|
+
bypass switch. A `check-wt.sh` that wraps this hook sets `KUMIKO_CHECK_WT=1`
|
|
80
|
+
so the hook does not call it again.
|
|
80
81
|
|
|
81
82
|
Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
|
|
82
83
|
before the main check in every branch (worktree, parent-scoped, and
|
package/hooks/pre-push
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
|
-
# PRE_PUSH_SKIP is checked here too so the bypass works even without node_modules.
|
|
3
|
-
if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
|
|
4
|
-
echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
|
|
5
|
-
exit 0
|
|
6
|
-
fi
|
|
7
|
-
|
|
8
2
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
9
3
|
if [ -z "$REPO_ROOT" ]; then
|
|
10
4
|
echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
|
|
@@ -33,7 +27,7 @@ done
|
|
|
33
27
|
|
|
34
28
|
if [ -z "$BIN" ]; then
|
|
35
29
|
echo "[pre-push] kumiko-pre-push not found in any node_modules/.bin above $REPO_ROOT." >&2
|
|
36
|
-
echo "Run \`bun install
|
|
30
|
+
echo "Run \`bun install\`." >&2
|
|
37
31
|
exit 1
|
|
38
32
|
fi
|
|
39
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.377.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
".": {
|
|
22
22
|
"types": "./src/index.ts",
|
|
23
23
|
"default": "./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./repo-manifest": {
|
|
26
|
+
"types": "./src/repo-manifest/index.ts",
|
|
27
|
+
"default": "./src/repo-manifest/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"./repo-manifest/app-ports": {
|
|
30
|
+
"types": "./src/repo-manifest/app-ports/index.ts",
|
|
31
|
+
"default": "./src/repo-manifest/app-ports/index.ts"
|
|
24
32
|
}
|
|
25
33
|
},
|
|
26
34
|
"bin": {
|
|
@@ -28,8 +36,8 @@
|
|
|
28
36
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
37
|
},
|
|
30
38
|
"dependencies": {
|
|
31
|
-
"
|
|
32
|
-
"
|
|
39
|
+
"ts-morph": "^28.0.0",
|
|
40
|
+
"zod": "^4.4.3"
|
|
33
41
|
},
|
|
34
42
|
"publishConfig": {
|
|
35
43
|
"registry": "https://registry.npmjs.org",
|
|
@@ -41,5 +49,16 @@
|
|
|
41
49
|
"hooks",
|
|
42
50
|
"README.md",
|
|
43
51
|
"LICENSE"
|
|
44
|
-
]
|
|
52
|
+
],
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@cosmicdrift/kumiko-framework": ">=0.366.0 <1.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@cosmicdrift/kumiko-framework": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.377.0"
|
|
63
|
+
}
|
|
45
64
|
}
|
package/src/_lib/agent-docs.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { type ExportedDeclarations, type JSDocableNode, Node, Project, ts } from
|
|
|
14
14
|
export const AGENT_DOCS_DIR = "packages/framework/agents";
|
|
15
15
|
|
|
16
16
|
const FEATURE_MANIFEST_REL = "samples/apps/use-all-bundled/feature-manifest.json";
|
|
17
|
-
const RENDERER_WEB_INDEX_REL = "packages/renderer
|
|
17
|
+
const RENDERER_WEB_INDEX_REL = "packages/renderer/src/web/index.ts";
|
|
18
18
|
const RECIPES_REL = "samples/recipes";
|
|
19
19
|
const RECIPE_SLUG_RE = /^[a-z0-9-]+$/;
|
|
20
20
|
const SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
@@ -286,7 +286,7 @@ function renderWidgets(repoRoot: string): string {
|
|
|
286
286
|
const rows = entries.map(([name, description]) => [`\`${name}\``, description]);
|
|
287
287
|
sections.push(`## ${group}\n\n${table(["Name", "Description"], rows)}`);
|
|
288
288
|
}
|
|
289
|
-
const intro = "Value exports of `@cosmicdrift/kumiko-renderer
|
|
289
|
+
const intro = "Value exports of `@cosmicdrift/kumiko-renderer/web`.";
|
|
290
290
|
return finishFile(RENDERER_WEB_INDEX_REL, `# Widgets\n\n${intro}\n\n${sections.join("\n\n")}`);
|
|
291
291
|
}
|
|
292
292
|
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { SourceFile } from "ts-morph";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const RENDERER_WEB_MODULES: ReadonlySet<string> = new Set([
|
|
4
|
+
"@cosmicdrift/kumiko-renderer/web",
|
|
5
|
+
"@cosmicdrift/kumiko-renderer-web",
|
|
6
|
+
]);
|
|
4
7
|
|
|
5
8
|
// Shared by every "raw HTML tag instead of framework primitive" guard
|
|
6
9
|
// (guard-no-custom-primitives, guard-raw-interactive-elements): proves the
|
|
7
10
|
// primitive set was reachable in this file — either an import from
|
|
8
|
-
// @cosmicdrift/kumiko-renderer
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
+
// @cosmicdrift/kumiko-renderer/web (any symbol; the deprecated
|
|
12
|
+
// @cosmicdrift/kumiko-renderer-web shim counts too), or a `usePrimitives`
|
|
13
|
+
// named import, which lives in the shared layer of @cosmicdrift/kumiko-renderer,
|
|
14
|
+
// not only in /web (see kumiko-enterprise/packages/ai-agent/src/web/turn-cards.tsx).
|
|
11
15
|
export function hasPrimitivesAccess(sf: SourceFile): boolean {
|
|
12
16
|
for (const imp of sf.getImportDeclarations()) {
|
|
13
|
-
if (imp.getModuleSpecifierValue()
|
|
17
|
+
if (RENDERER_WEB_MODULES.has(imp.getModuleSpecifierValue())) return true;
|
|
14
18
|
if (imp.getNamedImports().some((named) => named.getName() === "usePrimitives")) {
|
|
15
19
|
return true;
|
|
16
20
|
}
|
package/src/_lib/roots.ts
CHANGED
|
@@ -17,12 +17,12 @@ import {
|
|
|
17
17
|
type RepoManifest,
|
|
18
18
|
RepoManifestError,
|
|
19
19
|
type RepoManifestSource,
|
|
20
|
-
} from "
|
|
20
|
+
} from "../repo-manifest/index.ts";
|
|
21
21
|
import { gitEnv } from "./git-env";
|
|
22
22
|
|
|
23
|
-
export type { RepoKind } from "
|
|
23
|
+
export type { RepoKind } from "../repo-manifest/index.ts";
|
|
24
24
|
|
|
25
|
-
import type { RepoKind } from "
|
|
25
|
+
import type { RepoKind } from "../repo-manifest/index.ts";
|
|
26
26
|
|
|
27
27
|
// Worktrees (`.wt/kumiko-framework-*`) keep this package name, so it is stable across checkouts.
|
|
28
28
|
const FRAMEWORK_PACKAGE_NAME = "kumiko-framework";
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { join, sep } from "node:path";
|
|
3
|
-
import { type RepoKind, repoKindSchema } from "@cosmicdrift/kumiko-repo-manifest";
|
|
4
3
|
import { Glob } from "bun";
|
|
4
|
+
import { type RepoKind, repoKindSchema } from "../repo-manifest/index.ts";
|
|
5
5
|
import type { RepoRoot } from "./roots";
|
|
6
6
|
|
|
7
7
|
// Security-shaped guards name this explicitly: the default skips "tooling" roots, and a one-word manifest kind must not switch them off.
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.377.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "guard-unsafe-append-event",
|
|
6
|
+
"detail": "Flags ctx.unsafeAppendEvent outside migrations/, upcasters/ and tests. Existing call sites are frozen per file in .kumiko-unsafe-append-event-baseline.json and the count may only shrink; repos without the file get a warning only."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.377.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "repo-manifest moved into guards",
|
|
12
|
+
"detail": "The kumiko.json schema and loader are exported from @cosmicdrift/kumiko-guards/repo-manifest, the app port reader from @cosmicdrift/kumiko-guards/repo-manifest/app-ports. @cosmicdrift/kumiko-repo-manifest now only re-exports them and is removed after the consumer pass of wave 4. @cosmicdrift/kumiko-framework is an optional peer for the app-ports subpath.",
|
|
13
|
+
"migration": "Run bun kumiko-upgrade --apply, or bun kumiko codemod package-merge . --write. It rewrites imports of @cosmicdrift/kumiko-repo-manifest (and its /app-ports subpath) to @cosmicdrift/kumiko-guards/repo-manifest and replaces the dependency in package.json with kumiko-guards.",
|
|
14
|
+
"codemod": "scripts/codemod/package-merge.ts"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.377.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "renderer-boundaries guard scans headless and live for imports only",
|
|
20
|
+
"detail": "The headless and live subtrees of kumiko-renderer must not import react-dom, jsdom or renderer-web; the symbol and JSX rules of the shared layer no longer apply to them. The section-fields-raw scope follows the moved headless sources."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.377.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "renderer-boundaries guard leaves the web subtree unscanned",
|
|
26
|
+
"detail": "renderer/src/web may use the DOM and every web dependency; the shared renderer root and headless/live must not import it. The tailwind-scan-surface guard follows the new stylesheet location through package exports and shim imports."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.377.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "comment-lang touched mode pairs renames",
|
|
32
|
+
"detail": "The touched mode diffs a moved file against its old path, so moving a file no longer counts every German comment in it as new."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.376.0",
|
|
36
|
+
"type": "improvement",
|
|
37
|
+
"title": "The pre-push bypass is gone",
|
|
38
|
+
"detail": "PRE_PUSH_SKIP no longer skips the hook, neither in the shipped hook nor in kumiko-pre-push. KUMIKO_CHECK_WT=1 only stops the hook from re-entering a wrapping scripts/check-wt.sh. The i18n-keys guard treats keys defined by installed @cosmicdrift packages as known."
|
|
39
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "0.371.0",
|
|
4
42
|
"type": "improvement",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Guard: the generated agent docs under `packages/framework/agents/` must
|
|
4
4
|
* match what `bun run gen:agent-docs` renders from the current sources
|
|
5
|
-
* (feature manifest, renderer
|
|
5
|
+
* (feature manifest, renderer web exports, sample recipes), and every
|
|
6
6
|
* `agents/<file>.md` path the hand-written `packages/framework/AGENTS.md`
|
|
7
7
|
* points at must exist in that generated set.
|
|
8
8
|
*
|
|
@@ -266,6 +266,23 @@ function gitOrFail(args: readonly string[], cwd: string, message: string): strin
|
|
|
266
266
|
|
|
267
267
|
const REWRITE_HINT = `Translate new German comments to English (why, not what), or if deliberate:\n // ${IGNORE_TAG} <reason>`;
|
|
268
268
|
|
|
269
|
+
// A path-limited diff cannot pair a rename, so every comment of a moved file would read as added.
|
|
270
|
+
function readRenamedFrom(gitToplevel: string, mergeBase: string): Map<string, readonly string[]> {
|
|
271
|
+
const renamedFrom = new Map<string, readonly string[]>();
|
|
272
|
+
const fields = gitOrFail(
|
|
273
|
+
["diff", "--name-status", "-z", "-M", "--diff-filter=R", mergeBase, "HEAD"],
|
|
274
|
+
gitToplevel,
|
|
275
|
+
"Touched mode: git diff --name-status failed. Check the git error below.",
|
|
276
|
+
)
|
|
277
|
+
.split("\0")
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
280
|
+
const [, oldPath, newPath] = fields.slice(i, i + 3);
|
|
281
|
+
if (oldPath !== undefined && newPath !== undefined) renamedFrom.set(newPath, [oldPath]);
|
|
282
|
+
}
|
|
283
|
+
return renamedFrom;
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
function runTouchedMode(
|
|
270
287
|
argv: readonly string[],
|
|
271
288
|
root: string,
|
|
@@ -304,6 +321,8 @@ function runTouchedMode(
|
|
|
304
321
|
.split("\0")
|
|
305
322
|
.filter(Boolean);
|
|
306
323
|
|
|
324
|
+
const renamedFrom = readRenamedFrom(gitToplevel, mergeBase);
|
|
325
|
+
|
|
307
326
|
// The commits being pushed are checked, not the working tree: contents come from
|
|
308
327
|
// HEAD so line numbers match the `mergeBase..HEAD` diff. Scan scope is keyed
|
|
309
328
|
// relative to root, git output relative to the toplevel.
|
|
@@ -321,7 +340,17 @@ function runTouchedMode(
|
|
|
321
340
|
if (candidates.length === 0) continue;
|
|
322
341
|
const addedLines = parseAddedLineRanges(
|
|
323
342
|
gitOrFail(
|
|
324
|
-
[
|
|
343
|
+
[
|
|
344
|
+
"--literal-pathspecs",
|
|
345
|
+
"diff",
|
|
346
|
+
"-M",
|
|
347
|
+
"--unified=0",
|
|
348
|
+
mergeBase,
|
|
349
|
+
"HEAD",
|
|
350
|
+
"--",
|
|
351
|
+
file,
|
|
352
|
+
...(renamedFrom.get(file) ?? []),
|
|
353
|
+
],
|
|
325
354
|
gitToplevel,
|
|
326
355
|
`Touched mode: git diff --unified=0 failed for ${file}. Check the git error below.`,
|
|
327
356
|
),
|
|
@@ -46,8 +46,8 @@ const EXCLUDE =
|
|
|
46
46
|
// Prefixed by RepoRoot.name (not kind): kind "app" is shared across
|
|
47
47
|
// unregistered repos (infra#560).
|
|
48
48
|
|
|
49
|
-
// packages/
|
|
50
|
-
const ALLOWLIST = [/^kumiko-framework\/packages\/
|
|
49
|
+
// packages/framework/src/http/egress.ts IS the egress() implementation.
|
|
50
|
+
const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
|
|
51
51
|
const ALLOW_MARKER = "guard-allow: same-origin fetch";
|
|
52
52
|
|
|
53
53
|
interface Violation {
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Guard: HTML-building template literals must escape every interpolation.
|
|
4
4
|
*
|
|
5
5
|
* XSS protection in this codebase is a callsite convention
|
|
6
|
-
* (escapeHtml/escapeHtmlAttr from kumiko-headless) — this guard makes the
|
|
6
|
+
* (escapeHtml/escapeHtmlAttr from kumiko-renderer/headless) — this guard makes the
|
|
7
7
|
* convention enforceable. A template literal counts as HTML-building when
|
|
8
8
|
* its static text contains a known HTML/MJML/SVG tag. Every interpolation
|
|
9
9
|
* inside it must then be recognizably safe:
|
|
@@ -409,7 +409,7 @@ function scanFile(sf: SourceFile): UnsafeSite[] {
|
|
|
409
409
|
export const guard: AstGuard = {
|
|
410
410
|
name: "HTML-Escape Guard",
|
|
411
411
|
scan: SCAN,
|
|
412
|
-
hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
|
|
412
|
+
hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-renderer/headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
|
|
413
413
|
run(files) {
|
|
414
414
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
415
415
|
for (const sf of files) {
|
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\// },
|
|
@@ -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/**` and `src/live/**` only get the import rules:
|
|
10
|
+
* the apex page builder emits HTML strings and the live dispatcher is a fetch
|
|
11
|
+
* 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", "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}`,
|