@cosmicdrift/kumiko-guards 0.377.0 → 0.379.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 +3 -2
- package/hooks/pre-push +25 -5
- package/package.json +3 -3
- package/src/_lib/guards-doc.ts +89 -0
- package/src/_lib/run-slots.ts +181 -0
- package/src/changes.json +55 -0
- package/src/check-subpath-exports.ts +75 -0
- package/src/guard-api-object-form.ts +42 -28
- package/src/guard-app-feature-structure.ts +28 -1
- package/src/guard-direct-entity-writes.ts +315 -58
- package/src/guard-direct-fetch.ts +9 -8
- package/src/guard-escape-hatch-declared.ts +14 -0
- package/src/guard-guards-doc-drift.ts +47 -0
- package/src/guard-pre-es-patterns.ts +4 -0
- package/src/guard-renderer-boundaries.ts +4 -4
- package/src/guard-restricted-symbols.ts +9 -1
- package/src/index.ts +1 -0
- package/src/run-guards.ts +10 -3
- package/src/run-repo-checks.ts +4 -0
- package/src/runtime-isolation-classify.ts +5 -1
package/README.md
CHANGED
|
@@ -67,8 +67,9 @@ 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
|
package/hooks/pre-push
CHANGED
|
@@ -8,15 +8,35 @@ fi
|
|
|
8
8
|
# Walk up so a worktree without its own node_modules resolves via the parent
|
|
9
9
|
# install, but not past an enclosing repository: a bin above that boundary
|
|
10
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
|
|
11
20
|
BIN=""
|
|
12
21
|
DIR="$REPO_ROOT"
|
|
13
22
|
while true; do
|
|
14
|
-
|
|
15
|
-
if [
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
18
38
|
fi
|
|
19
|
-
if [ "$DIR" = "/" ]; then
|
|
39
|
+
if [ "$DIR" = "/" ] || { [ -n "$HOME_BOUND" ] && [ "$DIR" = "$HOME_BOUND" ]; }; then
|
|
20
40
|
break
|
|
21
41
|
fi
|
|
22
42
|
if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.379.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>",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.379.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.379.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const GUARDS_DOC_REL = "docs/reference/guards.md";
|
|
5
|
+
export const GUARDS_SRC_REL = "packages/guards/src";
|
|
6
|
+
|
|
7
|
+
const GUARD_FILE_RE = /^(guard|check)-.+\.ts$/;
|
|
8
|
+
const IGNORE_KEY_RE = /kumiko-lint-ignore ([a-z][a-z0-9-]*)/g;
|
|
9
|
+
const FRONT_MATTER_VERIFIED = "2026-10-20";
|
|
10
|
+
|
|
11
|
+
function headerComment(source: string): string {
|
|
12
|
+
const lines = source.split("\n").filter((line) => !line.startsWith("#!"));
|
|
13
|
+
const body: string[] = [];
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const text = line.trim();
|
|
16
|
+
if (text === "" && body.length === 0) continue;
|
|
17
|
+
if (
|
|
18
|
+
text.startsWith("/**") ||
|
|
19
|
+
text.startsWith("/*") ||
|
|
20
|
+
text.startsWith("*") ||
|
|
21
|
+
text.startsWith("//")
|
|
22
|
+
) {
|
|
23
|
+
body.push(
|
|
24
|
+
text
|
|
25
|
+
.replace(/^(\/\*\*?|\*\/|\*|\/\/)\s?/, "")
|
|
26
|
+
.replace(/\*\/$/, "")
|
|
27
|
+
.trim(),
|
|
28
|
+
);
|
|
29
|
+
if (text.endsWith("*/")) break;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
return body.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function purposeOf(source: string): string {
|
|
38
|
+
const paragraph = headerComment(source).split(/\n\s*\n/)[0] ?? "";
|
|
39
|
+
const flat = paragraph.replace(/\s+/g, " ").trim();
|
|
40
|
+
const sentenceEnd = flat.search(/\.(\s|$)/);
|
|
41
|
+
const sentence = sentenceEnd === -1 ? flat : flat.slice(0, sentenceEnd + 1);
|
|
42
|
+
return sentence.replace(/\|/g, "\\|") || "-";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ignoreKeysOf(source: string): string[] {
|
|
46
|
+
return [...new Set([...source.matchAll(IGNORE_KEY_RE)].map((match) => match[1] ?? ""))].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function guardSourceFiles(frameworkRoot: string): string[] {
|
|
50
|
+
return readdirSync(join(frameworkRoot, GUARDS_SRC_REL))
|
|
51
|
+
.filter((file) => GUARD_FILE_RE.test(file))
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderGuardsDoc(frameworkRoot: string): string {
|
|
56
|
+
const rows = guardSourceFiles(frameworkRoot).map((file) => {
|
|
57
|
+
const source = readFileSync(join(frameworkRoot, GUARDS_SRC_REL, file), "utf-8");
|
|
58
|
+
const keys = ignoreKeysOf(source);
|
|
59
|
+
const suppress =
|
|
60
|
+
keys.length === 0 ? "-" : keys.map((key) => `\`kumiko-lint-ignore ${key}\``).join(", ");
|
|
61
|
+
return `| \`${file.replace(/\.ts$/, "")}\` | ${purposeOf(source)} | ${suppress} |`;
|
|
62
|
+
});
|
|
63
|
+
return [
|
|
64
|
+
"---",
|
|
65
|
+
"status: reference",
|
|
66
|
+
`verified: ${FRONT_MATTER_VERIFIED}`,
|
|
67
|
+
`evidence: "generated from ${GUARDS_SRC_REL}/{guard,check}-*.ts by scripts/gen-guards-doc.ts; drift is enforced by the guards-doc-drift check"`,
|
|
68
|
+
"---",
|
|
69
|
+
"",
|
|
70
|
+
"# Guards",
|
|
71
|
+
"",
|
|
72
|
+
"Generated, do not edit. Run `bun run gen:guards-doc` after adding or changing a guard.",
|
|
73
|
+
"Purpose is the first sentence of the guard's header comment; the suppression column lists",
|
|
74
|
+
"the `kumiko-lint-ignore <key>` markers the guard honors (`-` = not suppressible inline).",
|
|
75
|
+
"",
|
|
76
|
+
"| Guard | Purpose | Suppression |",
|
|
77
|
+
"| --- | --- | --- |",
|
|
78
|
+
...rows,
|
|
79
|
+
"",
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function writeGuardsDoc(frameworkRoot: string): void {
|
|
84
|
+
writeFileSync(join(frameworkRoot, GUARDS_DOC_REL), renderGuardsDoc(frameworkRoot), "utf-8");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasGuardsSource(frameworkRoot: string): boolean {
|
|
88
|
+
return existsSync(join(frameworkRoot, GUARDS_SRC_REL));
|
|
89
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SLOTS = 2;
|
|
5
|
+
const POLL_INTERVAL_MS = 1000;
|
|
6
|
+
const PIDLESS_ORPHAN_AGE_MS = 60_000;
|
|
7
|
+
// Upper bound against pid reuse after SIGKILL/OOM: a reused pid would otherwise hold the slot forever.
|
|
8
|
+
const MAX_SLOT_AGE_MS = 15 * 60_000;
|
|
9
|
+
const PID_FILE = "pid";
|
|
10
|
+
|
|
11
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
12
|
+
|
|
13
|
+
export type GuardRunSlotOptions = {
|
|
14
|
+
readonly slotsDir?: string;
|
|
15
|
+
readonly environment?: Environment;
|
|
16
|
+
readonly pid?: number;
|
|
17
|
+
readonly isPidAlive?: (pid: number) => boolean;
|
|
18
|
+
readonly sleep?: (ms: number) => void;
|
|
19
|
+
readonly now?: () => number;
|
|
20
|
+
readonly log?: (line: string) => void;
|
|
21
|
+
// Test seam: real process hooks would intercept signals of the test runner.
|
|
22
|
+
readonly installProcessHooks?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// 0 = unlimited. CI runners are isolated, so slots only matter on a shared dev machine.
|
|
26
|
+
export function resolveGuardSlotCount(environment: Environment): number {
|
|
27
|
+
const ci = environment["CI"];
|
|
28
|
+
if (ci !== undefined && ci !== "") return 0;
|
|
29
|
+
const raw = environment["KUMIKO_GUARD_SLOTS"];
|
|
30
|
+
if (raw === undefined || !/^\d+$/.test(raw)) return DEFAULT_SLOTS;
|
|
31
|
+
return Number(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultIsPidAlive(pid: number): boolean {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// EPERM means the process exists but belongs to another user.
|
|
40
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Sync on purpose: runGuardsCli is sync and shared by the fw entry, the guards CLI and `kumiko check`.
|
|
45
|
+
function defaultSleep(ms: number): void {
|
|
46
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readSlotPid(slotPath: string): number | undefined {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = Number(readFileSync(join(slotPath, PID_FILE), "utf8").trim());
|
|
52
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isOrphanedSlot(
|
|
59
|
+
slotPath: string,
|
|
60
|
+
isPidAlive: (pid: number) => boolean,
|
|
61
|
+
nowMs: number,
|
|
62
|
+
): boolean {
|
|
63
|
+
let ageMs: number;
|
|
64
|
+
try {
|
|
65
|
+
ageMs = nowMs - statSync(slotPath).mtimeMs;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (ageMs > MAX_SLOT_AGE_MS) return true;
|
|
70
|
+
const ownerPid = readSlotPid(slotPath);
|
|
71
|
+
if (ownerPid !== undefined) return !isPidAlive(ownerPid);
|
|
72
|
+
// No pid file: the owner is either between mkdir and writeFile or died in that gap.
|
|
73
|
+
return ageMs > PIDLESS_ORPHAN_AGE_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function errorCode(error: unknown): string {
|
|
77
|
+
return error instanceof Error && "code" in error && typeof error.code === "string"
|
|
78
|
+
? error.code
|
|
79
|
+
: "unknown";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tryClaimSlot(slotPath: string, pid: number): boolean {
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(slotPath);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (errorCode(error) === "EEXIST") return false;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
writeFileSync(join(slotPath, PID_FILE), String(pid));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ponytail: two waiters judging the same orphan at once can let the slower one move the faster
|
|
94
|
+
// one's fresh claim aside, so N+1 runs overlap once. Acceptable for a memory throttle; a per-slot
|
|
95
|
+
// generation token would close it if it ever matters.
|
|
96
|
+
function removeOrphan(slotPath: string, pid: number): void {
|
|
97
|
+
// rename is atomic, so of several waiters only one deletes the orphan; mkdir stays the decider.
|
|
98
|
+
const graveyard = `${slotPath}.orphan-${pid}-${Math.random().toString(36).slice(2)}`;
|
|
99
|
+
try {
|
|
100
|
+
renameSync(slotPath, graveyard);
|
|
101
|
+
} catch {
|
|
102
|
+
// skip: another waiter already moved the orphan away
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
rmSync(graveyard, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createRelease(slotPath: string, pid: number, hooks: boolean): () => void {
|
|
109
|
+
let released = false;
|
|
110
|
+
const release = (): void => {
|
|
111
|
+
// skip: finally and the exit hook both call release
|
|
112
|
+
if (released) return;
|
|
113
|
+
released = true;
|
|
114
|
+
if (hooks) {
|
|
115
|
+
process.off("exit", release);
|
|
116
|
+
}
|
|
117
|
+
// A taken-over slot belongs to someone else by now; never delete theirs.
|
|
118
|
+
if (readSlotPid(slotPath) === pid) rmSync(slotPath, { recursive: true, force: true });
|
|
119
|
+
};
|
|
120
|
+
// No signal handlers: they would suppress default termination, and a signal death is
|
|
121
|
+
// reclaimed by the next run through the dead pid.
|
|
122
|
+
if (hooks) {
|
|
123
|
+
process.on("exit", release);
|
|
124
|
+
}
|
|
125
|
+
return release;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function claimFreeSlot(
|
|
129
|
+
slotsDir: string,
|
|
130
|
+
slotCount: number,
|
|
131
|
+
pid: number,
|
|
132
|
+
isPidAlive: (pid: number) => boolean,
|
|
133
|
+
now: () => number,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
for (let index = 0; index < slotCount; index++) {
|
|
136
|
+
const slotPath = join(slotsDir, `slot-${index}`);
|
|
137
|
+
if (tryClaimSlot(slotPath, pid)) return slotPath;
|
|
138
|
+
if (!isOrphanedSlot(slotPath, isPidAlive, now())) continue;
|
|
139
|
+
removeOrphan(slotPath, pid);
|
|
140
|
+
if (tryClaimSlot(slotPath, pid)) return slotPath;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fixed path, not os.tmpdir(): sandboxed sessions may have a different TMPDIR and would not share slots.
|
|
146
|
+
function defaultSlotsDir(): string {
|
|
147
|
+
const uid = process.getuid?.();
|
|
148
|
+
return uid === undefined ? "/tmp/kumiko-guard-slots" : `/tmp/kumiko-guard-slots-${uid}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function acquireGuardRunSlot(options: GuardRunSlotOptions = {}): () => void {
|
|
152
|
+
const slotCount = resolveGuardSlotCount(options.environment ?? process.env);
|
|
153
|
+
if (slotCount === 0) return () => {};
|
|
154
|
+
|
|
155
|
+
const slotsDir = options.slotsDir ?? defaultSlotsDir();
|
|
156
|
+
const pid = options.pid ?? process.pid;
|
|
157
|
+
const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
|
|
158
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
159
|
+
const now = options.now ?? Date.now;
|
|
160
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
161
|
+
const hooks = options.installProcessHooks ?? true;
|
|
162
|
+
|
|
163
|
+
let announced = false;
|
|
164
|
+
for (;;) {
|
|
165
|
+
let slotPath: string | undefined;
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(slotsDir, { recursive: true });
|
|
168
|
+
slotPath = claimFreeSlot(slotsDir, slotCount, pid, isPidAlive, now);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// A throttle must never block the push: fail open.
|
|
171
|
+
log(`→ guard slots unavailable (${errorCode(error)}), running without limit`);
|
|
172
|
+
return () => {};
|
|
173
|
+
}
|
|
174
|
+
if (slotPath !== undefined) return createRelease(slotPath, pid, hooks);
|
|
175
|
+
if (!announced) {
|
|
176
|
+
announced = true;
|
|
177
|
+
log(`→ waiting for a free guard slot (${slotCount} in use)`);
|
|
178
|
+
}
|
|
179
|
+
sleep(POLL_INTERVAL_MS);
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,59 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.379.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "App-feature-structure guard flags runtime-client markers",
|
|
6
|
+
"detail": "The guard reports a runtime-client marker in a feature file, outside web/ as client code in the server tree and inside web/ as redundant. dev/ is classified as the dev runtime by path."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.379.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Direct-entity-writes guard catches external and projection event-store tables",
|
|
12
|
+
"detail": "Published packages list their event-store tables in kumiko-es-tables.json, which the guard checks for staleness in the owning repo and reads in consumers. r.projection tables are now guarded as well. Existing consumers can get new findings that were previously hidden."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.379.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Guard runs queue for a free local slot",
|
|
18
|
+
"detail": "At most two full guard runs per machine run at the same time (KUMIKO_GUARD_SLOTS, default 2, 0 disables, unset in CI). Further runs print a waiting line and start when a slot frees up; slots of crashed runs are taken over automatically."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.379.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "direct-fetch guard uses kumiko-lint-ignore",
|
|
24
|
+
"detail": "The guard-allow direct-fetch marker is no longer parsed. The guard now honors kumiko-lint-ignore direct-fetch with a specific reason, like the other guards.",
|
|
25
|
+
"migration": "Replace guard-allow direct-fetch comments with kumiko-lint-ignore direct-fetch followed by the reason."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.379.0",
|
|
29
|
+
"type": "improvement",
|
|
30
|
+
"title": "Subpath-exports and guards-doc-drift checks",
|
|
31
|
+
"detail": "The subpath-exports check fails when a published package ships src/<feature>/web.ts without the matching exports key. The guards-doc-drift check keeps docs/reference/guards.md in sync with the guard sources, and a registry test fails when a guard file is not registered."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.378.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "Direct-entity-writes guard catches imported event-store tables",
|
|
37
|
+
"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."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.378.0",
|
|
41
|
+
"type": "improvement",
|
|
42
|
+
"title": "Faster, leaner guard run",
|
|
43
|
+
"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."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"version": "0.378.0",
|
|
47
|
+
"type": "fix",
|
|
48
|
+
"title": "Pre-push shim skips untrusted ancestors and stops at $HOME",
|
|
49
|
+
"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."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"version": "0.378.0",
|
|
53
|
+
"type": "fix",
|
|
54
|
+
"title": "Pre-push shim also vets node_modules/.bin",
|
|
55
|
+
"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."
|
|
56
|
+
},
|
|
2
57
|
{
|
|
3
58
|
"version": "0.377.0",
|
|
4
59
|
"type": "improvement",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* A published package that ships `src/<feature>/web.ts` must list `./<feature>/web` in its
|
|
4
|
+
* package.json `exports`; otherwise every standalone app build fails with "Could not resolve".
|
|
5
|
+
* Only "web" is checked: seeding/testing/constants subpaths are often internal on purpose.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
10
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
11
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
12
|
+
|
|
13
|
+
const SUBPATH_NAMES = ["web"] as const;
|
|
14
|
+
|
|
15
|
+
function readPublishedExportKeys(pkgDir: string): ReadonlySet<string> | undefined {
|
|
16
|
+
const pkgJsonPath = join(pkgDir, "package.json");
|
|
17
|
+
if (!existsSync(pkgJsonPath)) return undefined;
|
|
18
|
+
let manifest: unknown;
|
|
19
|
+
try {
|
|
20
|
+
manifest = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`${pkgJsonPath}: invalid JSON`, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
// Without publishConfig.registry the package is never shipped, so subpath drift does not apply.
|
|
25
|
+
const registry = propertyOf(propertyOf(manifest, "publishConfig"), "registry");
|
|
26
|
+
if (typeof registry !== "string") return undefined;
|
|
27
|
+
const exportsMap = propertyOf(manifest, "exports");
|
|
28
|
+
return new Set(isPlainObject(exportsMap) ? Object.keys(exportsMap) : []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const check: RepoCheck = {
|
|
32
|
+
name: "Subpath Exports",
|
|
33
|
+
hint:
|
|
34
|
+
"Add the missing subpath key to the package.json `exports` field, otherwise every " +
|
|
35
|
+
'standalone app build fails with "Could not resolve" on that import.',
|
|
36
|
+
run(roots: readonly RepoRoot[]) {
|
|
37
|
+
const violations: { file: string; line: number; message: string }[] = [];
|
|
38
|
+
let matchedFiles = 0;
|
|
39
|
+
for (const root of roots) {
|
|
40
|
+
const packagesDir = join(root.absPath, "packages");
|
|
41
|
+
if (!existsSync(packagesDir)) continue;
|
|
42
|
+
for (const entry of readdirSync(packagesDir)) {
|
|
43
|
+
const pkgDir = join(packagesDir, entry);
|
|
44
|
+
if (!statSync(pkgDir).isDirectory()) continue;
|
|
45
|
+
const exportKeys = readPublishedExportKeys(pkgDir);
|
|
46
|
+
const srcDir = join(pkgDir, "src");
|
|
47
|
+
if (exportKeys === undefined || !existsSync(srcDir)) continue;
|
|
48
|
+
matchedFiles++;
|
|
49
|
+
for (const feature of readdirSync(srcDir)) {
|
|
50
|
+
if (feature.startsWith("_")) continue;
|
|
51
|
+
// An unpublished feature folder needs no subpath exports either.
|
|
52
|
+
if (!statSync(join(srcDir, feature)).isDirectory()) continue;
|
|
53
|
+
if (!exportKeys.has(`./${feature}`)) continue;
|
|
54
|
+
for (const sub of SUBPATH_NAMES) {
|
|
55
|
+
const file = `packages/${entry}/src/${feature}/${sub}.ts`;
|
|
56
|
+
if (!existsSync(join(root.absPath, file)) || exportKeys.has(`./${feature}/${sub}`)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
violations.push({
|
|
60
|
+
file,
|
|
61
|
+
line: 1,
|
|
62
|
+
message: `"./${feature}/${sub}" is missing in packages/${entry}/package.json exports`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { violations, matchedFiles, notApplicable: matchedFiles === 0 };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (import.meta.main) {
|
|
73
|
+
const failed = reportResults(await runRepoChecks([check], resolveRepoRoots()));
|
|
74
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
75
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// App features follow the bundled-features convention (reference: tenant/):
|
|
3
3
|
// feature.ts = registration only, screens under web/, handlers under handlers/.
|
|
4
|
-
//
|
|
4
|
+
// A feature file's runtime follows from its path (web/** is client, the rest is server or
|
|
5
|
+
// isomorphic), so a runtime-client marker is never needed: outside web/ it hides client code
|
|
6
|
+
// in the server tree, inside web/ it is redundant. This guard flags the four most expensive deviations:
|
|
5
7
|
// 1. web.tsx/web.ts monolith or JSX screens directly at the feature root
|
|
6
8
|
// 2. feature.ts as a logic dump (> MAX_FEATURE_TS_LINES lines)
|
|
7
9
|
// 3. r.screen({ type: "custom" }) without an allowlist tag — declarative
|
|
8
10
|
// screen types (entityList/dashboard/data-table) are the default.
|
|
11
|
+
// 4. a runtime-client marker instead of the web/ path.
|
|
9
12
|
//
|
|
10
13
|
// ponytail: the handler file convention (*.query.ts/*.write.ts under handlers/)
|
|
11
14
|
// is not enforced yet — add it once the registration API shapes are
|
|
@@ -17,6 +20,7 @@ import * as path from "node:path";
|
|
|
17
20
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
18
21
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
19
22
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
23
|
+
import { directiveInHead } from "./runtime-isolation-classify";
|
|
20
24
|
|
|
21
25
|
const SCAN: ScanSpec = {
|
|
22
26
|
scope: "source",
|
|
@@ -111,6 +115,27 @@ function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
const DIRECTIVE_HEAD_CHARS = 600;
|
|
119
|
+
const FEATURE_RELATIVE_PATH = /(?:src\/features|packages\/bundled-features\/src)\/[^/]+\/(.+)$/;
|
|
120
|
+
|
|
121
|
+
// 4. runtime-client marker instead of the path
|
|
122
|
+
function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
|
|
123
|
+
const filePath = sf.getFilePath();
|
|
124
|
+
const featureRelative = FEATURE_RELATIVE_PATH.exec(filePath)?.[1];
|
|
125
|
+
const hasClientMarker =
|
|
126
|
+
featureRelative !== undefined &&
|
|
127
|
+
directiveInHead(sf.getFullText().slice(0, DIRECTIVE_HEAD_CHARS)) === "client";
|
|
128
|
+
if (!hasClientMarker || isIgnoredFile(sf)) return undefined;
|
|
129
|
+
const underWeb = featureRelative.startsWith("web/") || /^web\.tsx?$/.test(featureRelative);
|
|
130
|
+
return {
|
|
131
|
+
file: filePath,
|
|
132
|
+
line: 1,
|
|
133
|
+
message: underWeb
|
|
134
|
+
? "redundant @runtime client marker — files under web/ are client by path"
|
|
135
|
+
: "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
export const guard: AstGuard = {
|
|
115
140
|
name: "App-Feature-Structure Guard (App-Repos)",
|
|
116
141
|
scan: SCAN,
|
|
@@ -127,6 +152,8 @@ export const guard: AstGuard = {
|
|
|
127
152
|
checkRootJsx(sf, base, violations);
|
|
128
153
|
checkFeatureTsSize(sf, base, violations);
|
|
129
154
|
checkCustomScreens(sf, violations);
|
|
155
|
+
const markerViolation = clientMarkerViolation(sf);
|
|
156
|
+
if (markerViolation !== undefined) violations.push(markerViolation);
|
|
130
157
|
}
|
|
131
158
|
return { violations };
|
|
132
159
|
},
|