@cosmicdrift/kumiko-guards 0.378.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/package.json +3 -3
- package/src/_lib/guards-doc.ts +89 -0
- package/src/_lib/run-slots.ts +181 -0
- package/src/changes.json +31 -0
- package/src/check-subpath-exports.ts +75 -0
- package/src/guard-app-feature-structure.ts +28 -1
- package/src/guard-direct-entity-writes.ts +278 -53
- package/src/guard-direct-fetch.ts +9 -8
- package/src/guard-guards-doc-drift.ts +47 -0
- 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/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,35 @@
|
|
|
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
|
+
},
|
|
2
33
|
{
|
|
3
34
|
"version": "0.378.0",
|
|
4
35
|
"type": "fix",
|
|
@@ -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
|
+
}
|
|
@@ -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
|
},
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* (dash vs. underscore) but share the `ent` symbol. Plus an explicit
|
|
19
19
|
* `backingTable` override. Tables that are NOT statically resolvable
|
|
20
20
|
* are skipped (a miss is preferable to a false block).
|
|
21
|
+
* c) Tables of external packages (node_modules, workspace links), read from the
|
|
22
|
+
* `kumiko-es-tables.json` catalog each published package ships; a package whose
|
|
23
|
+
* sources live in the checked repo must keep that catalog current (checked here,
|
|
24
|
+
* rewritten with `--write-baseline --guard=<name>`).
|
|
25
|
+
* d) `r.projection({ table })` tables: event-fed read models, only the apply may write.
|
|
21
26
|
*
|
|
22
27
|
* 2. **Find direct writes.** Two forms:
|
|
23
28
|
* - Method form `<receiver>.insert(<tableIdent>)` / `.update` / `.delete`.
|
|
@@ -46,13 +51,21 @@
|
|
|
46
51
|
import * as path from "node:path";
|
|
47
52
|
import {
|
|
48
53
|
type CallExpression,
|
|
54
|
+
type FileSystemHost,
|
|
49
55
|
type Identifier,
|
|
50
56
|
Node,
|
|
51
57
|
type ObjectLiteralExpression,
|
|
58
|
+
type Project,
|
|
52
59
|
type SourceFile,
|
|
53
60
|
SyntaxKind,
|
|
54
61
|
} from "ts-morph";
|
|
55
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
ALL_REPO_KINDS,
|
|
64
|
+
type AstGuard,
|
|
65
|
+
isExternalSourcePath,
|
|
66
|
+
runStandalone,
|
|
67
|
+
type ScanSpec,
|
|
68
|
+
} from "./_lib/guard-kit";
|
|
56
69
|
|
|
57
70
|
const ROOT = process.cwd();
|
|
58
71
|
|
|
@@ -95,46 +108,6 @@ const FN_WRITE_HELPERS = new Map<string, "insert" | "update" | "delete">([
|
|
|
95
108
|
["deleteManyBatched", "delete"],
|
|
96
109
|
]);
|
|
97
110
|
|
|
98
|
-
// Writes that are deliberately outside the event-store executor. Match on file + enclosing function + table so the exception cannot widen silently.
|
|
99
|
-
type DirectWriteException = {
|
|
100
|
-
readonly file: RegExp;
|
|
101
|
-
readonly functionName: string;
|
|
102
|
-
readonly table: string;
|
|
103
|
-
readonly justification: string;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
|
|
107
|
-
{
|
|
108
|
-
file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
|
|
109
|
-
functionName: "resolveExistingForEventStoreSeed",
|
|
110
|
-
table: "templateResourcesTable",
|
|
111
|
-
justification: "Orphan projection row without events, no event-store verb possible",
|
|
112
|
-
},
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
// First named container: `function f`, `const f = () =>`, or `run: () =>` in an object literal.
|
|
116
|
-
function enclosingFunctionName(node: Node): string | undefined {
|
|
117
|
-
for (const ancestor of node.getAncestors()) {
|
|
118
|
-
if (Node.isFunctionDeclaration(ancestor)) return ancestor.getName();
|
|
119
|
-
if (Node.isVariableDeclaration(ancestor) || Node.isPropertyAssignment(ancestor)) {
|
|
120
|
-
return ancestor.getName();
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isDirectWriteException(call: CallExpression, table: string): boolean {
|
|
127
|
-
const functionName = enclosingFunctionName(call);
|
|
128
|
-
if (functionName === undefined) return false;
|
|
129
|
-
const file = call.getSourceFile().getFilePath();
|
|
130
|
-
return DIRECT_WRITE_EXCEPTIONS.some(
|
|
131
|
-
(exception) =>
|
|
132
|
-
exception.file.test(file) &&
|
|
133
|
-
exception.functionName === functionName &&
|
|
134
|
-
exception.table === table,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
111
|
// `table as EntityTableMeta`, `(table)`, `table!`, `<T>table`, `table satisfies T` all name the same table.
|
|
139
112
|
export function unwrapTableExpression(node: Node): Node {
|
|
140
113
|
let current = node;
|
|
@@ -288,6 +261,39 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
288
261
|
return tables;
|
|
289
262
|
}
|
|
290
263
|
|
|
264
|
+
const PROJECTION_METHOD_ACCESS = /\.\s*projection\b/;
|
|
265
|
+
|
|
266
|
+
// The `table` of `r.projection({ table, apply })`: an event-fed read model, so only its apply
|
|
267
|
+
// (tx receiver) may write it. Shorthand `{ table }` is a name-only reference to the same symbol.
|
|
268
|
+
export function collectProjectionTables(files: readonly SourceFile[]): Set<TableId> {
|
|
269
|
+
const tables = new Set<TableId>();
|
|
270
|
+
for (const sf of files) {
|
|
271
|
+
if (!PROJECTION_METHOD_ACCESS.test(sf.getFullText())) continue;
|
|
272
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
273
|
+
const callee = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
|
|
274
|
+
if (callee?.getName() !== "projection") continue;
|
|
275
|
+
const definition = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
276
|
+
const property = definition?.getProperty("table");
|
|
277
|
+
const identifier =
|
|
278
|
+
property?.asKind(SyntaxKind.ShorthandPropertyAssignment)?.getNameNode() ??
|
|
279
|
+
unwrapTableExpression(
|
|
280
|
+
property?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer() ?? call,
|
|
281
|
+
).asKind(SyntaxKind.Identifier);
|
|
282
|
+
const did = identifier && declIdOf(identifier);
|
|
283
|
+
if (did) tables.add(did);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return tables;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function collectScopeEsTables(files: readonly SourceFile[]): Set<TableId> {
|
|
290
|
+
return new Set<TableId>([
|
|
291
|
+
...collectEsTables(files),
|
|
292
|
+
...collectEntityProjectionTables(files),
|
|
293
|
+
...collectProjectionTables(files),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
|
|
291
297
|
type Violation = {
|
|
292
298
|
file: string;
|
|
293
299
|
line: number;
|
|
@@ -456,9 +462,218 @@ function importedBindingNames(sf: SourceFile): Set<string> {
|
|
|
456
462
|
return names;
|
|
457
463
|
}
|
|
458
464
|
|
|
465
|
+
const ES_TABLE_CATALOG_FILE = "kumiko-es-tables.json";
|
|
466
|
+
|
|
467
|
+
type CatalogEntry = { readonly module: string; readonly export: string };
|
|
468
|
+
|
|
469
|
+
function tableIdFilePath(tableId: TableId): string {
|
|
470
|
+
return tableId.slice(0, tableId.lastIndexOf("::"));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function tableIdName(tableId: TableId): string {
|
|
474
|
+
return tableId.slice(tableId.lastIndexOf("::") + 2);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function packageRootOf(host: FileSystemHost, filePath: string): string | undefined {
|
|
478
|
+
let dir = path.dirname(filePath);
|
|
479
|
+
while (dir !== path.dirname(dir)) {
|
|
480
|
+
if (host.fileExistsSync(path.join(dir, "package.json"))) return dir;
|
|
481
|
+
dir = path.dirname(dir);
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Source (`src/x.ts`, workspace link) and published (`dist/x.d.ts`) layouts must yield one key.
|
|
487
|
+
function moduleOf(packageRoot: string, filePath: string): string {
|
|
488
|
+
return path
|
|
489
|
+
.relative(packageRoot, filePath)
|
|
490
|
+
.split(path.sep)
|
|
491
|
+
.join("/")
|
|
492
|
+
.replace(/^(src|dist)\//, "")
|
|
493
|
+
.replace(/\.d\.ts$|\.(ts|tsx|js)$/, "");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function catalogKey(module: string, exportName: string): string {
|
|
497
|
+
return `${module}::${exportName}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isCatalogEntry(value: unknown): value is CatalogEntry {
|
|
501
|
+
return (
|
|
502
|
+
typeof value === "object" &&
|
|
503
|
+
value !== null &&
|
|
504
|
+
"module" in value &&
|
|
505
|
+
"export" in value &&
|
|
506
|
+
typeof value.module === "string" &&
|
|
507
|
+
typeof value.export === "string"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function parseCatalog(text: string): CatalogEntry[] | undefined {
|
|
512
|
+
try {
|
|
513
|
+
const parsed: unknown = JSON.parse(text);
|
|
514
|
+
return Array.isArray(parsed) && parsed.every(isCatalogEntry) ? parsed : undefined;
|
|
515
|
+
} catch {
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function readCatalogKeys(host: FileSystemHost, packageRoot: string): Set<string> {
|
|
521
|
+
const catalogPath = path.join(packageRoot, ES_TABLE_CATALOG_FILE);
|
|
522
|
+
if (!host.fileExistsSync(catalogPath)) return new Set();
|
|
523
|
+
const entries = parseCatalog(host.readFileSync(catalogPath)) ?? [];
|
|
524
|
+
return new Set(entries.map((entry) => catalogKey(entry.module, entry.export)));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export type ExternalEsTables = { has(tableId: TableId): boolean };
|
|
528
|
+
|
|
529
|
+
// A published package ships `kumiko-es-tables.json` (module + export of its ES tables), because
|
|
530
|
+
// its sources are not on disk in a consumer (only dist/*.d.ts). Missing catalog: no ES tables.
|
|
531
|
+
export function createExternalEsTables(project: Project): ExternalEsTables {
|
|
532
|
+
const host = project.getFileSystem();
|
|
533
|
+
const keysByPackageRoot = new Map<string, ReadonlySet<string>>();
|
|
534
|
+
return {
|
|
535
|
+
has(tableId) {
|
|
536
|
+
const filePath = tableIdFilePath(tableId);
|
|
537
|
+
const root = packageRootOf(host, filePath);
|
|
538
|
+
if (root === undefined) return false;
|
|
539
|
+
let keys = keysByPackageRoot.get(root);
|
|
540
|
+
if (!keys) {
|
|
541
|
+
keys = readCatalogKeys(host, root);
|
|
542
|
+
keysByPackageRoot.set(root, keys);
|
|
543
|
+
}
|
|
544
|
+
return keys.has(catalogKey(moduleOf(root, filePath), tableIdName(tableId)));
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isPublishedPackage(host: FileSystemHost, packageRoot: string): boolean {
|
|
550
|
+
try {
|
|
551
|
+
const manifest: unknown = JSON.parse(host.readFileSync(path.join(packageRoot, "package.json")));
|
|
552
|
+
return !(
|
|
553
|
+
typeof manifest === "object" &&
|
|
554
|
+
manifest !== null &&
|
|
555
|
+
"private" in manifest &&
|
|
556
|
+
manifest.private === true
|
|
557
|
+
);
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Owner side: the exported scope ES tables of every published package, grouped by package root.
|
|
564
|
+
function deriveCatalogs(
|
|
565
|
+
files: readonly SourceFile[],
|
|
566
|
+
esTables: ReadonlySet<TableId>,
|
|
567
|
+
): Map<string, CatalogEntry[]> {
|
|
568
|
+
const catalogs = new Map<string, CatalogEntry[]>();
|
|
569
|
+
const firstFile = files[0];
|
|
570
|
+
if (!firstFile) return catalogs;
|
|
571
|
+
const project = firstFile.getProject();
|
|
572
|
+
const host = project.getFileSystem();
|
|
573
|
+
const scopePaths = new Set<string>(files.map((file) => file.getFilePath()));
|
|
574
|
+
const publishedByRoot = new Map<string, boolean>();
|
|
575
|
+
for (const tableId of esTables) {
|
|
576
|
+
const filePath = tableIdFilePath(tableId);
|
|
577
|
+
if (!scopePaths.has(filePath) || EXCLUDE.test(filePath)) continue;
|
|
578
|
+
const name = tableIdName(tableId);
|
|
579
|
+
if (!project.getSourceFile(filePath)?.getVariableDeclaration(name)?.isExported()) continue;
|
|
580
|
+
const root = packageRootOf(host, filePath);
|
|
581
|
+
if (root === undefined) continue;
|
|
582
|
+
let published = publishedByRoot.get(root);
|
|
583
|
+
if (published === undefined) {
|
|
584
|
+
published = isPublishedPackage(host, root);
|
|
585
|
+
publishedByRoot.set(root, published);
|
|
586
|
+
}
|
|
587
|
+
if (!published) continue;
|
|
588
|
+
const entries = catalogs.get(root) ?? [];
|
|
589
|
+
entries.push({ module: moduleOf(root, filePath), export: name });
|
|
590
|
+
catalogs.set(root, entries);
|
|
591
|
+
}
|
|
592
|
+
for (const [root, entries] of catalogs) {
|
|
593
|
+
entries.sort(
|
|
594
|
+
(a, b) => a.module.localeCompare(b.module, "en") || a.export.localeCompare(b.export, "en"),
|
|
595
|
+
);
|
|
596
|
+
catalogs.set(root, [
|
|
597
|
+
...new Map(entries.map((e) => [catalogKey(e.module, e.export), e])).values(),
|
|
598
|
+
]);
|
|
599
|
+
}
|
|
600
|
+
return catalogs;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const REWRITE_CATALOGS_COMMAND =
|
|
604
|
+
'bun packages/guards/src/run-guards.ts --write-baseline --guard="Direct-Entity-Writes Guard"';
|
|
605
|
+
|
|
606
|
+
// Published packages whose catalog exists on disk although they define no ES table any more.
|
|
607
|
+
function publishedRootsWithCatalog(files: readonly SourceFile[]): Set<string> {
|
|
608
|
+
const roots = new Set<string>();
|
|
609
|
+
const firstFile = files[0];
|
|
610
|
+
if (!firstFile) return roots;
|
|
611
|
+
const host = firstFile.getProject().getFileSystem();
|
|
612
|
+
const rootByDir = new Map<string, string | undefined>();
|
|
613
|
+
for (const sf of files) {
|
|
614
|
+
const dir = path.dirname(sf.getFilePath());
|
|
615
|
+
if (!rootByDir.has(dir)) rootByDir.set(dir, packageRootOf(host, dir));
|
|
616
|
+
const root = rootByDir.get(dir);
|
|
617
|
+
if (root !== undefined && host.fileExistsSync(path.join(root, ES_TABLE_CATALOG_FILE))) {
|
|
618
|
+
roots.add(root);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return roots;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function catalogViolations(
|
|
625
|
+
files: readonly SourceFile[],
|
|
626
|
+
esTables: ReadonlySet<TableId>,
|
|
627
|
+
): { file: string; line: number; message: string; neverFrozen: boolean }[] {
|
|
628
|
+
const firstFile = files[0];
|
|
629
|
+
if (!firstFile) return [];
|
|
630
|
+
const host = firstFile.getProject().getFileSystem();
|
|
631
|
+
const expected = deriveCatalogs(files, esTables);
|
|
632
|
+
const roots = new Set([...expected.keys(), ...publishedRootsWithCatalog(files)]);
|
|
633
|
+
const violations: { file: string; line: number; message: string; neverFrozen: boolean }[] = [];
|
|
634
|
+
for (const root of roots) {
|
|
635
|
+
const wanted = new Set((expected.get(root) ?? []).map((e) => catalogKey(e.module, e.export)));
|
|
636
|
+
const actual = readCatalogKeys(host, root);
|
|
637
|
+
const missing = [...wanted].filter((key) => !actual.has(key));
|
|
638
|
+
const surplus = [...actual].filter((key) => !wanted.has(key));
|
|
639
|
+
if (missing.length === 0 && surplus.length === 0) continue;
|
|
640
|
+
const catalogPath = path.join(root, ES_TABLE_CATALOG_FILE);
|
|
641
|
+
const detail = [
|
|
642
|
+
missing.length > 0 ? `missing: ${missing.join(", ")}` : "",
|
|
643
|
+
surplus.length > 0 ? `surplus: ${surplus.join(", ")}` : "",
|
|
644
|
+
]
|
|
645
|
+
.filter(Boolean)
|
|
646
|
+
.join("; ");
|
|
647
|
+
violations.push({
|
|
648
|
+
file: path.relative(ROOT, catalogPath),
|
|
649
|
+
line: 0,
|
|
650
|
+
message: `ES table catalog out of date (${detail}). Rewrite it: ${REWRITE_CATALOGS_COMMAND}`,
|
|
651
|
+
neverFrozen: true,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return violations;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function writeCatalogs(files: readonly SourceFile[], esTables: ReadonlySet<TableId>): void {
|
|
658
|
+
const firstFile = files[0];
|
|
659
|
+
if (!firstFile) throw new Error("No scanned files: refusing to rewrite ES table catalogs");
|
|
660
|
+
const host = firstFile.getProject().getFileSystem();
|
|
661
|
+
const expected = deriveCatalogs(files, esTables);
|
|
662
|
+
for (const root of publishedRootsWithCatalog(files)) {
|
|
663
|
+
if (!expected.has(root)) host.deleteSync(path.join(root, ES_TABLE_CATALOG_FILE));
|
|
664
|
+
}
|
|
665
|
+
for (const [root, entries] of expected) {
|
|
666
|
+
host.writeFileSync(
|
|
667
|
+
path.join(root, ES_TABLE_CATALOG_FILE),
|
|
668
|
+
`${JSON.stringify(entries, null, 2)}\n`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
459
673
|
export function scanDirectWrites(
|
|
460
674
|
sf: SourceFile,
|
|
461
675
|
esTables: ReadonlySet<TableId>,
|
|
676
|
+
externalEsTables: ExternalEsTables = createExternalEsTables(sf.getProject()),
|
|
462
677
|
): Omit<Violation, "file">[] {
|
|
463
678
|
const out: Omit<Violation, "file">[] = [];
|
|
464
679
|
// Superset of every name a write may use for an ES table: definition names plus import bindings.
|
|
@@ -478,8 +693,10 @@ export function scanDirectWrites(
|
|
|
478
693
|
const receiverName = receiver.getText();
|
|
479
694
|
|
|
480
695
|
const did = declIdOf(w.tableArg);
|
|
481
|
-
if (!did
|
|
482
|
-
if (
|
|
696
|
+
if (!did) continue;
|
|
697
|
+
if (!esTables.has(did)) {
|
|
698
|
+
if (!isExternalSourcePath(tableIdFilePath(did)) || !externalEsTables.has(did)) continue;
|
|
699
|
+
}
|
|
483
700
|
|
|
484
701
|
if (TX_RECEIVER_NAMES.has(receiverName)) {
|
|
485
702
|
// A tx receiver is only legitimate when the write actually sits
|
|
@@ -518,14 +735,9 @@ export const guard: AstGuard = {
|
|
|
518
735
|
"Write ES-entity tables through the entity write handlers / event-store executor or inside an r.projection apply (tx receiver); a direct insert/update/delete lets the projection row drift from the event stream. " +
|
|
519
736
|
"For a BLOCKED scan, check that the scan still covers the files declaring r.entity / createEventStoreExecutor.",
|
|
520
737
|
run(files) {
|
|
521
|
-
// Merge
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
// entity tables go undetected.
|
|
525
|
-
const esTables = new Set<TableId>([
|
|
526
|
-
...collectEsTables(files),
|
|
527
|
-
...collectEntityProjectionTables(files),
|
|
528
|
-
]);
|
|
738
|
+
// Merge all collections BEFORE the empty-set check — otherwise a repo that only uses r.entity
|
|
739
|
+
// would skip every scan: the canary fires falsely and writes on entity tables go undetected.
|
|
740
|
+
const esTables = collectScopeEsTables(files);
|
|
529
741
|
if (esTables.size === 0) {
|
|
530
742
|
// An empty set means either the repo has no event store at all, or the
|
|
531
743
|
// scan lost the ES definitions while table writes are still present.
|
|
@@ -542,13 +754,23 @@ export const guard: AstGuard = {
|
|
|
542
754
|
};
|
|
543
755
|
}
|
|
544
756
|
|
|
545
|
-
const violations: Array<{
|
|
757
|
+
const violations: Array<{
|
|
758
|
+
file: string;
|
|
759
|
+
line: number;
|
|
760
|
+
message: string;
|
|
761
|
+
neverFrozen?: boolean;
|
|
762
|
+
}> = catalogViolations(files, esTables);
|
|
763
|
+
|
|
764
|
+
const firstFile = files[0];
|
|
765
|
+
const externalEsTables = firstFile
|
|
766
|
+
? createExternalEsTables(firstFile.getProject())
|
|
767
|
+
: { has: () => false };
|
|
546
768
|
|
|
547
769
|
for (const sf of files) {
|
|
548
770
|
const file = sf.getFilePath();
|
|
549
771
|
if (EXCLUDE.test(file)) continue;
|
|
550
772
|
|
|
551
|
-
for (const hit of scanDirectWrites(sf, esTables)) {
|
|
773
|
+
for (const hit of scanDirectWrites(sf, esTables, externalEsTables)) {
|
|
552
774
|
violations.push({
|
|
553
775
|
file: path.relative(ROOT, file),
|
|
554
776
|
line: hit.line,
|
|
@@ -559,6 +781,9 @@ export const guard: AstGuard = {
|
|
|
559
781
|
|
|
560
782
|
return { violations };
|
|
561
783
|
},
|
|
784
|
+
writeBaseline(files) {
|
|
785
|
+
writeCatalogs(files, collectScopeEsTables(files));
|
|
786
|
+
},
|
|
562
787
|
};
|
|
563
788
|
|
|
564
789
|
// Run only when invoked as a script — tests import the helpers without
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
runStandalone,
|
|
32
32
|
type ScanSpec,
|
|
33
33
|
} from "./_lib/guard-kit";
|
|
34
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
34
35
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
35
36
|
|
|
36
37
|
const SCAN: ScanSpec = {
|
|
@@ -48,7 +49,9 @@ const EXCLUDE =
|
|
|
48
49
|
|
|
49
50
|
// packages/framework/src/http/egress.ts IS the egress() implementation.
|
|
50
51
|
const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
|
|
51
|
-
const
|
|
52
|
+
const IGNORE_TAG = "kumiko-lint-ignore direct-fetch";
|
|
53
|
+
const IGNORE_TAG_WITH_REASON_RE =
|
|
54
|
+
/kumiko-lint-ignore\s+(?:[\w-]+,)*direct-fetch(?:,[\w-]+)*(?=\s|$)(.*)$/;
|
|
52
55
|
|
|
53
56
|
interface Violation {
|
|
54
57
|
line: number;
|
|
@@ -61,14 +64,12 @@ const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
|
|
|
61
64
|
|
|
62
65
|
// The marker is a per-line skip for a security guard, so it only counts with a
|
|
63
66
|
// non-generic reason after it — a bare marker still reports the finding.
|
|
64
|
-
function
|
|
67
|
+
function hasIgnoreTagWithReason(sf: SourceFile, line: number): boolean {
|
|
65
68
|
const lines = sf.getFullText().split("\n");
|
|
66
69
|
const idx = line - 1;
|
|
67
70
|
return [lines[idx] ?? "", lines[idx - 1] ?? ""].some((text) => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const reason = text
|
|
71
|
-
.slice(markerAt + ALLOW_MARKER.length)
|
|
71
|
+
if (!lineHasIgnoreTag(text, IGNORE_TAG)) return false;
|
|
72
|
+
const reason = (IGNORE_TAG_WITH_REASON_RE.exec(text)?.[1] ?? "")
|
|
72
73
|
.replace(/^\s*(?:—|–|-|:)\s*/, "")
|
|
73
74
|
.trim();
|
|
74
75
|
return !isGenericReason(reason);
|
|
@@ -127,7 +128,7 @@ export const guard: AstGuard = {
|
|
|
127
128
|
security: true,
|
|
128
129
|
hint:
|
|
129
130
|
"Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
|
|
130
|
-
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `//
|
|
131
|
+
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// kumiko-lint-ignore direct-fetch <specific reason>` on the line above (a bare marker does not count). ' +
|
|
131
132
|
"Local bindings named fetch are flagged (false positive — unblock via that marker). " +
|
|
132
133
|
'Known gap: bracket access (globalThis["fetch"](...)).',
|
|
133
134
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
@@ -142,7 +143,7 @@ export const guard: AstGuard = {
|
|
|
142
143
|
root === undefined ? undefined : `${root.name}/${pathRelative(root.absPath, file)}`;
|
|
143
144
|
if (key !== undefined && isAllowlisted(key, ALLOWLIST)) continue;
|
|
144
145
|
for (const v of findRawFetchCalls(sf)) {
|
|
145
|
-
if (
|
|
146
|
+
if (hasIgnoreTagWithReason(sf, v.line)) continue;
|
|
146
147
|
violations.push({
|
|
147
148
|
// cwd-relative, not repo-relative `rel` — the security baseline
|
|
148
149
|
// needs an unambiguous path to resolve back to (repo, relPath).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Fails when docs/reference/guards.md differs from what the generator renders from the guard sources.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
8
|
+
import {
|
|
9
|
+
GUARDS_DOC_REL,
|
|
10
|
+
GUARDS_SRC_REL,
|
|
11
|
+
hasGuardsSource,
|
|
12
|
+
renderGuardsDoc,
|
|
13
|
+
} from "./_lib/guards-doc";
|
|
14
|
+
|
|
15
|
+
const REGENERATE_HINT = "run `bun run gen:guards-doc` and commit the result";
|
|
16
|
+
|
|
17
|
+
export const check: RepoCheck = {
|
|
18
|
+
name: "guards-doc-drift",
|
|
19
|
+
hint: "Run `bun run gen:guards-doc` and commit docs/reference/guards.md.",
|
|
20
|
+
run(roots) {
|
|
21
|
+
const frameworkRoots = roots.filter((root) => hasGuardsSource(root.absPath));
|
|
22
|
+
const violations: { file: string; line: number; message: string }[] = [];
|
|
23
|
+
for (const root of frameworkRoots) {
|
|
24
|
+
const docPath = join(root.absPath, GUARDS_DOC_REL);
|
|
25
|
+
const expected = renderGuardsDoc(root.absPath);
|
|
26
|
+
if (!existsSync(docPath)) {
|
|
27
|
+
violations.push({ file: GUARDS_DOC_REL, line: 1, message: `missing; ${REGENERATE_HINT}` });
|
|
28
|
+
} else if (readFileSync(docPath, "utf-8") !== expected) {
|
|
29
|
+
violations.push({
|
|
30
|
+
file: GUARDS_DOC_REL,
|
|
31
|
+
line: 1,
|
|
32
|
+
message: `differs from the generated output (sources in ${GUARDS_SRC_REL}); ${REGENERATE_HINT}`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
violations,
|
|
38
|
+
matchedFiles: frameworkRoots.length,
|
|
39
|
+
notApplicable: frameworkRoots.length === 0,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (import.meta.main) {
|
|
45
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
46
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
47
|
+
}
|
|
@@ -42,6 +42,10 @@ const RESTRICTED_SYMBOLS = new Set([
|
|
|
42
42
|
// an audited unsafeRaw escape hatch, so new callers need an explicit allowlist entry.
|
|
43
43
|
"appendEventInTenantDb",
|
|
44
44
|
"getStreamVersionInTenantDb",
|
|
45
|
+
// Grants the ES-write capability on the tenant being destroyed; only the destroy stage and its definition may use it.
|
|
46
|
+
"createTenantDataDestroyDb",
|
|
47
|
+
// Test seam that writes event-fed tables without events; runtime code importing it would be a back door.
|
|
48
|
+
"withExecutorManagedWritesForTests",
|
|
45
49
|
]);
|
|
46
50
|
|
|
47
51
|
const ALLOWLIST = [
|
|
@@ -50,6 +54,10 @@ const ALLOWLIST = [
|
|
|
50
54
|
/^packages\/framework\/src\/event-store\/tenant-db-append\.ts$/,
|
|
51
55
|
/^packages\/bundled-features\/src\/cap-counter\/cap-reservation\.ts$/,
|
|
52
56
|
/^packages\/bundled-features\/src\/user-data-rights-defaults\/hooks\/delivery-attempt\.userdata-hook\.ts$/,
|
|
57
|
+
/^packages\/framework\/src\/db\/tenant-db\.ts$/,
|
|
58
|
+
/^packages\/framework\/src\/db\/index\.ts$/,
|
|
59
|
+
/^packages\/bundled-features\/src\/tenant-lifecycle\/stages\.ts$/,
|
|
60
|
+
/^packages\/testing\/src\/helpers\/(seed|db-cleanup)\.ts$/,
|
|
53
61
|
/^packages\/bundled-features\/src\/tenant\/seeding\.ts$/,
|
|
54
62
|
/^packages\/bundled-features\/src\/tier-engine\/feature\.ts$/,
|
|
55
63
|
];
|
|
@@ -121,7 +129,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
|
|
|
121
129
|
export const guard: AstGuard = {
|
|
122
130
|
name: "Restricted-Symbols Guard",
|
|
123
131
|
scan: SCAN,
|
|
124
|
-
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.appendEvent — only framework-internal coordination code may use them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
|
|
132
|
+
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.appendEvent — only framework-internal coordination code may use them. createTenantDataDestroyDb grants direct writes on event-fed tables and belongs to the tenant-destroy stage only. withExecutorManagedWritesForTests is the test-seed seam behind seedRow/deleteRows and must not reach runtime code. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
|
|
125
133
|
run(files, roots = resolveRepoRoots()) {
|
|
126
134
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
127
135
|
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export {
|
|
|
21
21
|
type ScanSpec,
|
|
22
22
|
type SuiteInventory,
|
|
23
23
|
} from "./_lib/guard-kit";
|
|
24
|
+
export { GUARDS_DOC_REL, renderGuardsDoc, writeGuardsDoc } from "./_lib/guards-doc";
|
|
24
25
|
export { findLocalRepo, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
25
26
|
export { type RootScan, type ScanOptions, scanFiles, scanRoots } from "./_lib/scan-scope";
|
|
26
27
|
export {
|
package/src/run-guards.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
runGuards,
|
|
19
19
|
} from "./_lib/guard-kit";
|
|
20
20
|
import type { RepoRoot } from "./_lib/roots";
|
|
21
|
+
import { acquireGuardRunSlot } from "./_lib/run-slots";
|
|
21
22
|
import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
|
|
22
23
|
import { guard as asCasts } from "./check-as-casts";
|
|
23
24
|
import { guard as complexity } from "./check-complexity";
|
|
@@ -176,9 +177,15 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
176
177
|
}
|
|
177
178
|
const strictSecurityBaseline = flags.includes("--strict-security-baseline");
|
|
178
179
|
const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
// Taken before the ts-morph project is built: that is where the memory goes.
|
|
181
|
+
const releaseSlot = acquireGuardRunSlot();
|
|
182
|
+
try {
|
|
183
|
+
const project = buildSharedProject(guards);
|
|
184
|
+
printGuardKitBanner(guards.length, project);
|
|
185
|
+
return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
|
|
186
|
+
} finally {
|
|
187
|
+
releaseSlot();
|
|
188
|
+
}
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
// Only run on direct invocation — otherwise `import { GUARDS }` would kick
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { check as realProviderIsolation } from "./check-real-provider-isolation"
|
|
|
7
7
|
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
8
8
|
import { check as secretLiterals } from "./check-secret-literals";
|
|
9
9
|
import { check as singleRuntimeInstance } from "./check-single-runtime-instance";
|
|
10
|
+
import { check as subpathExports } from "./check-subpath-exports";
|
|
10
11
|
import { check as agentDocsDrift } from "./guard-agent-docs-drift";
|
|
11
12
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
13
|
+
import { check as guardsDocDrift } from "./guard-guards-doc-drift";
|
|
12
14
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
13
15
|
import { check as primitivesDiscipline } from "./guard-primitives-discipline";
|
|
14
16
|
import { check as rawSql } from "./guard-raw-sql";
|
|
@@ -31,6 +33,8 @@ export const REPO_CHECKS = [
|
|
|
31
33
|
realProviderIsolation,
|
|
32
34
|
singleRuntimeInstance,
|
|
33
35
|
agentDocsDrift,
|
|
36
|
+
subpathExports,
|
|
37
|
+
guardsDocDrift,
|
|
34
38
|
];
|
|
35
39
|
|
|
36
40
|
export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];
|
|
@@ -49,6 +49,7 @@ const PATH_RULES: readonly (readonly [RegExp, Runtime])[] = [
|
|
|
49
49
|
// sample and import kumiko-testing/e2e; without this rule they read as
|
|
50
50
|
// runtime code importing the test tier. Deliberately not any nested e2e/.
|
|
51
51
|
[/^(?:e2e|packages\/[^/]+\/e2e|samples\/(?:.+\/)?e2e)\//, "test"],
|
|
52
|
+
[/(?:^|\/)dev\//, "dev"],
|
|
52
53
|
[/(?:^|\/)scripts\//, "tooling"],
|
|
53
54
|
[/(?:^|\/)bin\//, "tooling"],
|
|
54
55
|
[/\/drizzle\/[^/]+\.ts$/, "tooling"],
|
|
@@ -347,7 +348,10 @@ export function classifyByDirective(
|
|
|
347
348
|
readHead: HeadReader = readFileHead,
|
|
348
349
|
): Runtime | null {
|
|
349
350
|
const head = readHead(filePath);
|
|
350
|
-
|
|
351
|
+
return head === null ? null : directiveInHead(head);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function directiveInHead(head: string): Runtime | null {
|
|
351
355
|
for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
|
|
352
356
|
const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
|
|
353
357
|
const declared = m?.[1];
|