@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.378.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": ">=0.366.0 <1.0.0"
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.378.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
- // This guard flags the three most expensive deviations:
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 { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
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 || !esTables.has(did)) continue;
482
- if (isDirectWriteException(call, w.tableArg.getText())) continue;
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 both collections BEFORE the empty-set check — otherwise a repo
522
- // that only uses r.entity (no createEventStoreExecutor anymore) would
523
- // skip every scan: the canary violation fires falsely, and writes on
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<{ file: string; line: number; message: string }> = [];
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 ALLOW_MARKER = "guard-allow: same-origin fetch";
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 hasAllowMarkerWithReason(sf: SourceFile, line: number): boolean {
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
- const markerAt = text.indexOf(ALLOW_MARKER);
69
- if (markerAt < 0) return false;
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 `// guard-allow: same-origin fetch — <specific reason>` on the line above (a bare marker does not count). ' +
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 (hasAllowMarkerWithReason(sf, v.line)) continue;
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
- const project = buildSharedProject(guards);
180
- printGuardKitBanner(guards.length, project);
181
- return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
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
@@ -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
- if (head === null) return null;
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];