@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/README.md
CHANGED
|
@@ -67,16 +67,18 @@ drops it.
|
|
|
67
67
|
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
|
-
without their own `node_modules` resolve via the parent install
|
|
71
|
-
|
|
70
|
+
without their own `node_modules` resolve via the parent install, up to and
|
|
71
|
+
including `$HOME`, skipping ancestors owned by another user or writable by
|
|
72
|
+
group/others) and fails closed with a `bun install` hint when none exists. Inside
|
|
72
73
|
the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
|
|
73
74
|
the pushing repo only; in a standalone clone it runs the repo's own
|
|
74
75
|
`package.json` `test` script. From a `.wt/<name>` worktree under the parent
|
|
75
76
|
workspace it runs a tracked `scripts/check-wt.sh` if present, otherwise the
|
|
76
77
|
worktree's own `package.json` scripts `typecheck`, `lint`, `test`, and
|
|
77
78
|
`test:dom` (whichever are declared), directly in the worktree — the parent's
|
|
78
|
-
`bun check` would otherwise check the main checkout instead.
|
|
79
|
-
`
|
|
79
|
+
`bun check` would otherwise check the main checkout instead. There is no
|
|
80
|
+
bypass switch. A `check-wt.sh` that wraps this hook sets `KUMIKO_CHECK_WT=1`
|
|
81
|
+
so the hook does not call it again.
|
|
80
82
|
|
|
81
83
|
Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
|
|
82
84
|
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
|
|
@@ -14,15 +8,35 @@ fi
|
|
|
14
8
|
# Walk up so a worktree without its own node_modules resolves via the parent
|
|
15
9
|
# install, but not past an enclosing repository: a bin above that boundary
|
|
16
10
|
# belongs to another project. `[ -x ]` also skips a broken (uninstalled) symlink.
|
|
11
|
+
# A bin in an ancestor that is owned by someone else or group/world-writable
|
|
12
|
+
# (/tmp, /Users/Shared) is attacker-plantable and must never be exec'd, and
|
|
13
|
+
# $HOME is the upper trust boundary (inclusive), compared by physical path
|
|
14
|
+
# because git prints REPO_ROOT resolved: a trailing slash or a symlinked home
|
|
15
|
+
# would otherwise never match and the walk would continue to /.
|
|
16
|
+
HOME_BOUND=""
|
|
17
|
+
if [ -n "$HOME" ] && [ -d "$HOME" ]; then
|
|
18
|
+
HOME_BOUND="$(cd "$HOME" 2>/dev/null && pwd -P)"
|
|
19
|
+
fi
|
|
17
20
|
BIN=""
|
|
18
21
|
DIR="$REPO_ROOT"
|
|
19
22
|
while true; do
|
|
20
|
-
|
|
21
|
-
if [
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
TRUSTED=1
|
|
24
|
+
if [ "$DIR" != "$REPO_ROOT" ]; then
|
|
25
|
+
if [ ! -O "$DIR" ] || [ -n "$(find "$DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
|
|
26
|
+
TRUSTED=0
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
if [ "$TRUSTED" = 1 ]; then
|
|
30
|
+
BIN_DIR="$DIR/node_modules/.bin"
|
|
31
|
+
CANDIDATE="$BIN_DIR/kumiko-pre-push"
|
|
32
|
+
# The bin dir itself must be user-owned and not group/world-writable, or a
|
|
33
|
+
# planted entry could replace the real bin.
|
|
34
|
+
if [ -x "$CANDIDATE" ] && [ -O "$BIN_DIR" ] && [ -z "$(find "$BIN_DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
|
|
35
|
+
BIN="$CANDIDATE"
|
|
36
|
+
break
|
|
37
|
+
fi
|
|
24
38
|
fi
|
|
25
|
-
if [ "$DIR" = "/" ]; then
|
|
39
|
+
if [ "$DIR" = "/" ] || { [ -n "$HOME_BOUND" ] && [ "$DIR" = "$HOME_BOUND" ]; }; then
|
|
26
40
|
break
|
|
27
41
|
fi
|
|
28
42
|
if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
|
|
@@ -33,7 +47,7 @@ done
|
|
|
33
47
|
|
|
34
48
|
if [ -z "$BIN" ]; then
|
|
35
49
|
echo "[pre-push] kumiko-pre-push not found in any node_modules/.bin above $REPO_ROOT." >&2
|
|
36
|
-
echo "Run \`bun install
|
|
50
|
+
echo "Run \`bun install\`." >&2
|
|
37
51
|
exit 1
|
|
38
52
|
fi
|
|
39
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.378.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.378.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,66 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Direct-entity-writes guard catches imported event-store tables",
|
|
6
|
+
"detail": "Table identifiers are resolved through import aliases to their definition, so direct insert/update/delete on an event-store table imported from another file of the scanned repo is no longer missed. Findings now show the full receiver (ctx.db). Existing consumers can get new findings that were previously hidden."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.378.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Faster, leaner guard run",
|
|
12
|
+
"detail": "Four AST guards skip files that cannot contain their anchor names before walking every node, and Direct-Entity-Writes only resolves symbols for table arguments named like an event-sourced table. Findings are identical."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.378.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Pre-push shim skips untrusted ancestors and stops at $HOME",
|
|
18
|
+
"detail": "The shim walked up to / and could exec a kumiko-pre-push planted in /tmp or another shared directory. The walk now ends at $HOME and skips ancestors that another user owns or that group or others can write to."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.378.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Pre-push shim also vets node_modules/.bin",
|
|
24
|
+
"detail": "The shim now requires node_modules/.bin itself to be owned by the user and not group or world writable before executing kumiko-pre-push from it, in the guards hook and the dev-server husky template."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.377.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "guard-unsafe-append-event",
|
|
30
|
+
"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."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.377.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "repo-manifest moved into guards",
|
|
36
|
+
"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.",
|
|
37
|
+
"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.",
|
|
38
|
+
"codemod": "scripts/codemod/package-merge.ts"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.377.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "renderer-boundaries guard scans headless and live for imports only",
|
|
44
|
+
"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."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "0.377.0",
|
|
48
|
+
"type": "improvement",
|
|
49
|
+
"title": "renderer-boundaries guard leaves the web subtree unscanned",
|
|
50
|
+
"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."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.377.0",
|
|
54
|
+
"type": "improvement",
|
|
55
|
+
"title": "comment-lang touched mode pairs renames",
|
|
56
|
+
"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."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "0.376.0",
|
|
60
|
+
"type": "improvement",
|
|
61
|
+
"title": "The pre-push bypass is gone",
|
|
62
|
+
"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."
|
|
63
|
+
},
|
|
2
64
|
{
|
|
3
65
|
"version": "0.371.0",
|
|
4
66
|
"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
|
*
|
|
@@ -41,6 +41,8 @@ const REGISTRAR_TYPE_MARKER = "FeatureRegistrar";
|
|
|
41
41
|
const FEATURE_FACTORY_NAME = "defineFeature";
|
|
42
42
|
const REGISTRAR_RECEIVER_NAMES: ReadonlySet<string> = new Set(["r", "registrar"]);
|
|
43
43
|
const EXCLUDED_PATH = /(\/__tests__\/fixtures\/|\.d\.ts$)/;
|
|
44
|
+
// Cheap text superset of what positionalCallMethod can match; skips the AST walk for most files.
|
|
45
|
+
const REGISTRAR_METHOD_ACCESS = new RegExp(`\\.\\s*(?:${REGISTRAR_METHODS.join("|")})\\b`);
|
|
44
46
|
const UPGRADE_HINT = "run `bun kumiko-upgrade --apply` (codemod object-form)";
|
|
45
47
|
|
|
46
48
|
type ReceiverResolution = "registrar" | "other" | "unresolved";
|
|
@@ -145,39 +147,51 @@ function positionalCallMethod(call: CallExpression): RegistrarMethod | undefined
|
|
|
145
147
|
|
|
146
148
|
export type ObjectFormFinding = { file: string; line: number; message: string };
|
|
147
149
|
|
|
150
|
+
function positionalCallFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
|
|
151
|
+
if (!REGISTRAR_METHOD_ACCESS.test(sf.getFullText())) return [];
|
|
152
|
+
const out: ObjectFormFinding[] = [];
|
|
153
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
154
|
+
const method = positionalCallMethod(call);
|
|
155
|
+
if (method === undefined) continue;
|
|
156
|
+
out.push({
|
|
157
|
+
file,
|
|
158
|
+
line: call.getStartLineNumber(),
|
|
159
|
+
message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function crudFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
|
|
166
|
+
// Every crud local name is CRUD_FUNCTION_NAME itself or an import alias of it.
|
|
167
|
+
if (!sf.getFullText().includes(CRUD_FUNCTION_NAME)) return [];
|
|
168
|
+
const crudNames = crudLocalNames(sf);
|
|
169
|
+
const out: ObjectFormFinding[] = [];
|
|
170
|
+
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
171
|
+
if (!crudNames.has(id.getText())) continue;
|
|
172
|
+
const parent = id.getParent();
|
|
173
|
+
const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
|
|
174
|
+
const isImportOrExport =
|
|
175
|
+
Node.isImportSpecifier(parent) ||
|
|
176
|
+
Node.isExportSpecifier(parent) ||
|
|
177
|
+
id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
|
|
178
|
+
if (!isCall && !isImportOrExport) continue;
|
|
179
|
+
if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
|
|
180
|
+
out.push({
|
|
181
|
+
file,
|
|
182
|
+
line: id.getStartLineNumber(),
|
|
183
|
+
message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
148
189
|
export function findPositionalRegistrarCalls(files: readonly SourceFile[]): ObjectFormFinding[] {
|
|
149
190
|
const out: ObjectFormFinding[] = [];
|
|
150
191
|
for (const sf of files) {
|
|
151
192
|
if (EXCLUDED_PATH.test(sf.getFilePath())) continue;
|
|
152
193
|
const file = relativeToCwd(sf.getFilePath());
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
156
|
-
const method = positionalCallMethod(call);
|
|
157
|
-
if (method === undefined) continue;
|
|
158
|
-
out.push({
|
|
159
|
-
file,
|
|
160
|
-
line: call.getStartLineNumber(),
|
|
161
|
-
message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
166
|
-
if (!crudNames.has(id.getText())) continue;
|
|
167
|
-
const parent = id.getParent();
|
|
168
|
-
const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
|
|
169
|
-
const isImportOrExport =
|
|
170
|
-
Node.isImportSpecifier(parent) ||
|
|
171
|
-
Node.isExportSpecifier(parent) ||
|
|
172
|
-
id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
|
|
173
|
-
if (!isCall && !isImportOrExport) continue;
|
|
174
|
-
if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
|
|
175
|
-
out.push({
|
|
176
|
-
file,
|
|
177
|
-
line: id.getStartLineNumber(),
|
|
178
|
-
message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
194
|
+
out.push(...positionalCallFindings(sf, file), ...crudFindings(sf, file));
|
|
181
195
|
}
|
|
182
196
|
return out;
|
|
183
197
|
}
|
|
@@ -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
|
),
|
|
@@ -156,21 +156,26 @@ export function unwrapTableExpression(node: Node): Node {
|
|
|
156
156
|
// declaration disambiguates them.
|
|
157
157
|
type TableId = string; // "${filePath}::${name}"
|
|
158
158
|
|
|
159
|
-
function declIdOf(id:
|
|
159
|
+
function declIdOf(id: Identifier): TableId | undefined {
|
|
160
160
|
const symbol = id.getSymbol();
|
|
161
161
|
if (!symbol) return undefined;
|
|
162
|
-
|
|
162
|
+
// An imported identifier's own declaration is the ImportSpecifier; follow the alias
|
|
163
|
+
// chain (barrels, renames) so the ID matches the one built at the definition.
|
|
164
|
+
const resolved = symbol.isAlias() ? (symbol.getAliasedSymbol() ?? symbol) : symbol;
|
|
165
|
+
const decls = resolved.getDeclarations();
|
|
163
166
|
if (decls.length === 0) return undefined;
|
|
164
167
|
// Param-only symbols are factory pass-throughs — skip.
|
|
165
168
|
if (decls.every((d) => d.getKind() === SyntaxKind.Parameter)) return undefined;
|
|
166
169
|
const first = decls[0];
|
|
167
170
|
if (!first) return undefined;
|
|
168
|
-
|
|
171
|
+
const name = Node.hasName(first) ? first.getName() : id.getText();
|
|
172
|
+
return `${first.getSourceFile().getFilePath()}::${name}`;
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
|
|
172
176
|
const tables = new Set<TableId>();
|
|
173
177
|
for (const sf of files) {
|
|
178
|
+
if (!sf.getFullText().includes("createEventStoreExecutor")) continue;
|
|
174
179
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
175
180
|
const expr = call.getExpression();
|
|
176
181
|
if (expr.getText() !== "createEventStoreExecutor") continue;
|
|
@@ -251,6 +256,8 @@ function rebuildableEntityTableDeclId(
|
|
|
251
256
|
return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
const ENTITY_METHOD_ACCESS = /\.\s*entity\b/;
|
|
260
|
+
|
|
254
261
|
// Tables registered via `r.entity({ name, ...entityDef })` as a rebuildable implicit
|
|
255
262
|
// projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
|
|
256
263
|
export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
|
|
@@ -259,6 +266,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
259
266
|
|
|
260
267
|
// Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
|
|
261
268
|
for (const sf of files) {
|
|
269
|
+
if (!ENTITY_METHOD_ACCESS.test(sf.getFullText())) continue;
|
|
262
270
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
263
271
|
const registration = entityRegistrationOf(call);
|
|
264
272
|
if (!registration) continue;
|
|
@@ -270,6 +278,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
270
278
|
// Pass 2: `const xTable = buildEntityTable(<name>, <entityDef>)` where entityDef
|
|
271
279
|
// is in the rebuildable set → xTable is a rebuildable entity table.
|
|
272
280
|
for (const sf of files) {
|
|
281
|
+
if (!sf.getFullText().includes("buildEntityTable")) continue;
|
|
273
282
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
274
283
|
const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
|
|
275
284
|
if (did) tables.add(did);
|
|
@@ -427,14 +436,37 @@ function hasAnyTableWriteOrDeclaration(files: readonly SourceFile[]): boolean {
|
|
|
427
436
|
return false;
|
|
428
437
|
}
|
|
429
438
|
|
|
439
|
+
// A TableId ends in `::<identifier text>`, so a table argument whose text is no ES table name
|
|
440
|
+
// can never match; filtering on the name avoids a symbol lookup (TS program build) per call.
|
|
441
|
+
function tableNamesOf(tableIds: ReadonlySet<TableId>): Set<string> {
|
|
442
|
+
return new Set([...tableIds].map((tableId) => tableId.slice(tableId.lastIndexOf("::") + 2)));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Local names an import can give a table in this file (`import { a as b }` -> b). Syntactic,
|
|
446
|
+
// so a rename or barrel re-export never hides an ES table from the name prefilter.
|
|
447
|
+
function importedBindingNames(sf: SourceFile): Set<string> {
|
|
448
|
+
const names = new Set<string>();
|
|
449
|
+
for (const declaration of sf.getImportDeclarations()) {
|
|
450
|
+
const defaultName = declaration.getDefaultImport()?.getText();
|
|
451
|
+
if (defaultName) names.add(defaultName);
|
|
452
|
+
for (const specifier of declaration.getNamedImports()) {
|
|
453
|
+
names.add(specifier.getAliasNode()?.getText() ?? specifier.getName());
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return names;
|
|
457
|
+
}
|
|
458
|
+
|
|
430
459
|
export function scanDirectWrites(
|
|
431
460
|
sf: SourceFile,
|
|
432
461
|
esTables: ReadonlySet<TableId>,
|
|
433
462
|
): Omit<Violation, "file">[] {
|
|
434
463
|
const out: Omit<Violation, "file">[] = [];
|
|
464
|
+
// Superset of every name a write may use for an ES table: definition names plus import bindings.
|
|
465
|
+
const candidateNames = new Set([...tableNamesOf(esTables), ...importedBindingNames(sf)]);
|
|
435
466
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
436
467
|
const w = resolveWrite(call);
|
|
437
468
|
if (!w) continue;
|
|
469
|
+
if (!candidateNames.has(w.tableArg.getText())) continue;
|
|
438
470
|
|
|
439
471
|
// Walk the receiver chain down to its leftmost identifier. For `db.insert`
|
|
440
472
|
// that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
|
|
@@ -457,7 +489,7 @@ export function scanDirectWrites(
|
|
|
457
489
|
if (isInsideProjectionApply(call)) continue;
|
|
458
490
|
out.push({
|
|
459
491
|
line: call.getStartLineNumber(),
|
|
460
|
-
receiver:
|
|
492
|
+
receiver: w.receiver.getText(),
|
|
461
493
|
op: w.op,
|
|
462
494
|
table: w.tableArg.getText(),
|
|
463
495
|
snippet: call.getText().slice(0, 120),
|
|
@@ -468,7 +500,7 @@ export function scanDirectWrites(
|
|
|
468
500
|
|
|
469
501
|
out.push({
|
|
470
502
|
line: call.getStartLineNumber(),
|
|
471
|
-
receiver:
|
|
503
|
+
receiver: w.receiver.getText(),
|
|
472
504
|
op: w.op,
|
|
473
505
|
table: w.tableArg.getText(),
|
|
474
506
|
snippet: call.getText().slice(0, 120),
|
|
@@ -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 {
|
|
@@ -128,6 +128,7 @@ function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
|
|
|
128
128
|
export function systemScopeDirs(files: readonly SourceFile[]): string[] {
|
|
129
129
|
const dirs: string[] = [];
|
|
130
130
|
for (const sf of scannableFiles(files)) {
|
|
131
|
+
if (!sf.getFullText().includes("systemScope")) continue;
|
|
131
132
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
132
133
|
const expr = call.getExpression();
|
|
133
134
|
if (
|
|
@@ -189,6 +190,10 @@ function isTenantDbExpression(node: Node): boolean {
|
|
|
189
190
|
return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
// Text prefilters: a node walk per rule per file is the guard's dominant cost, and
|
|
194
|
+
// a rule's anchor name must appear verbatim in any file it can fire on.
|
|
195
|
+
const RAW_ACCESS = /\.\s*raw\b/;
|
|
196
|
+
|
|
192
197
|
function findRawFindings(
|
|
193
198
|
sf: SourceFile,
|
|
194
199
|
root: string,
|
|
@@ -196,6 +201,7 @@ function findRawFindings(
|
|
|
196
201
|
): EscapeHatchFinding[] {
|
|
197
202
|
if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
|
|
198
203
|
const out: EscapeHatchFinding[] = [];
|
|
204
|
+
if (!RAW_ACCESS.test(sf.getFullText())) return out;
|
|
199
205
|
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
200
206
|
if (pa.getName() !== "raw") continue;
|
|
201
207
|
if (!isTenantDbExpression(pa.getExpression())) continue;
|
|
@@ -217,6 +223,7 @@ function findUnsafeRawFindings(
|
|
|
217
223
|
): EscapeHatchFinding[] {
|
|
218
224
|
if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
|
|
219
225
|
const out: EscapeHatchFinding[] = [];
|
|
226
|
+
if (!sf.getFullText().includes("unsafeRaw")) return out;
|
|
220
227
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
221
228
|
const expr = call.getExpression();
|
|
222
229
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
@@ -531,6 +538,8 @@ function findSystemIdentityFindings(
|
|
|
531
538
|
systemDirs: readonly string[],
|
|
532
539
|
): EscapeHatchFinding[] {
|
|
533
540
|
const out: EscapeHatchFinding[] = [];
|
|
541
|
+
const text = sf.getFullText();
|
|
542
|
+
if (!text.includes("queryAs") && !text.includes("writeAs")) return out;
|
|
534
543
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
535
544
|
const expr = call.getExpression();
|
|
536
545
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
@@ -562,6 +571,7 @@ function findUnsafeAllTenantsFindings(
|
|
|
562
571
|
systemDirs: readonly string[],
|
|
563
572
|
): EscapeHatchFinding[] {
|
|
564
573
|
const out: EscapeHatchFinding[] = [];
|
|
574
|
+
if (!sf.getFullText().includes("unsafeAllTenants")) return out;
|
|
565
575
|
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
566
576
|
const nameNode = prop.getNameNode();
|
|
567
577
|
if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "unsafeAllTenants") {
|
|
@@ -609,6 +619,8 @@ const GENERIC_REASON_METHODS = new Set(["acknowledgeCrossTenant", "unsafeRaw"]);
|
|
|
609
619
|
|
|
610
620
|
function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReasonFinding[] {
|
|
611
621
|
const out: GenericReasonFinding[] = [];
|
|
622
|
+
const text = sf.getFullText();
|
|
623
|
+
if (![...GENERIC_REASON_METHODS].some((method) => text.includes(method))) return out;
|
|
612
624
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
613
625
|
const expr = call.getExpression();
|
|
614
626
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
@@ -634,6 +646,7 @@ function findGenericReasonDeclareEscapeHatchCalls(
|
|
|
634
646
|
root: string,
|
|
635
647
|
): GenericReasonFinding[] {
|
|
636
648
|
const out: GenericReasonFinding[] = [];
|
|
649
|
+
if (!sf.getFullText().includes("declareEscapeHatch")) return out;
|
|
637
650
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
638
651
|
const callee = call.getExpression();
|
|
639
652
|
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
@@ -662,6 +675,7 @@ function findGenericReasonObjectProperty(
|
|
|
662
675
|
propertyName: string,
|
|
663
676
|
): GenericReasonFinding[] {
|
|
664
677
|
const out: GenericReasonFinding[] = [];
|
|
678
|
+
if (!sf.getFullText().includes(propertyName)) return out;
|
|
665
679
|
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
666
680
|
if (pa.getName() !== propertyName) continue;
|
|
667
681
|
const init = pa.getInitializer();
|
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) {
|