@cosmicdrift/kumiko-guards 0.387.0 → 0.389.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.
@@ -41,30 +41,6 @@
41
41
  "reviewedBy": "Marc, 2026-05-06",
42
42
  "expires": "2027-05-06"
43
43
  },
44
- {
45
- "package": "@cosmicdriftgamestudio/*",
46
- "version": "*",
47
- "license": "UNLICENSED",
48
- "reason": "Eigene private CDGS-Workspace-Packages (kumiko-guards, später kumiko-ai-*, kumiko-designer etc.). UNLICENSED = proprietary, kein Public-License-Risk weil intern in der Org bleibt (GH-Packages access:restricted).",
49
- "reviewedBy": "Marc, 2026-05-06",
50
- "expires": "2030-05-06"
51
- },
52
- {
53
- "package": "@cosmicdrift/*",
54
- "version": "*",
55
- "license": "BUSL-1.1",
56
- "reason": "Eigene Public-Workspace-Packages (kumiko-framework + 6 weitere). Wir sind der Licensor — BUSL-1.1 ist unser eigener Lizenz-Choice für die public NPM-Pakete. Der License-Check denied BUSL-1.1 als generic-Pattern (für Third-Party-Deps korrekt), aber für eigene Packages ist's der designierte Lizenz.",
57
- "reviewedBy": "Marc, 2026-05-06",
58
- "expires": "2030-05-06"
59
- },
60
- {
61
- "package": "create-kumiko-app",
62
- "version": "*",
63
- "license": "BUSL-1.1",
64
- "reason": "Unscoped Workspace-Paket — `bun create kumiko-app` resolved zu `bunx create-kumiko-app`, daher KEIN @cosmicdrift/-Scope möglich. Analog zur @cosmicdrift/*-Exception unser eigener Lizenz-Choice; eigene Eintrag weil Wildcard-Pattern unscoped Pakete nicht matched.",
65
- "reviewedBy": "Marc, 2026-06-21",
66
- "expires": "2030-06-21"
67
- },
68
44
  {
69
45
  "package": "khroma",
70
46
  "version": "*",
@@ -116,6 +116,8 @@ function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void
116
116
  const DIRECTIVE_HEAD_CHARS = 600;
117
117
  const FEATURE_RELATIVE_PATH = /(?:src\/features|packages\/bundled-features\/src)\/[^/]+\/(.+)$/;
118
118
 
119
+ const PATH_IMPLIED_RUNTIME_DIRS = ["dev/", "scripts/"];
120
+
119
121
  // 4. runtime-client marker instead of the path
120
122
  function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
121
123
  const filePath = sf.getFilePath();
@@ -125,13 +127,13 @@ function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
125
127
  directiveInHead(sf.getFullText().slice(0, DIRECTIVE_HEAD_CHARS)) === "client";
126
128
  if (!hasClientMarker || isIgnoredFile(sf)) return undefined;
127
129
  const underWeb = featureRelative.startsWith("web/") || /^web\.tsx?$/.test(featureRelative);
128
- return {
129
- file: filePath,
130
- line: 1,
131
- message: underWeb
132
- ? "redundant @runtime client marker — files under web/ are client by path"
133
- : "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)",
134
- };
130
+ const runtimeFromPath = PATH_IMPLIED_RUNTIME_DIRS.some((dir) => featureRelative.startsWith(dir));
131
+ const message = underWeb
132
+ ? "redundant @runtime client marker — files under web/ are client by path"
133
+ : runtimeFromPath
134
+ ? "@runtime client marker under dev/ or scripts/ — the path already sets the runtime; remove the marker (bun kumiko codemod feature-layout . --write)"
135
+ : "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)";
136
+ return { file: filePath, line: 1, message };
135
137
  }
136
138
 
137
139
  export const guard: AstGuard = {
@@ -42,11 +42,13 @@ const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
42
42
 
43
43
  // `fw#12`, `#1234`, `kumiko-framework#3`, `oven-sh/bun#5`; `'` is an HTML entity.
44
44
  const REFERENCE_RE = /(?<!&)[\w./-]*#\d+(?![\w])/g;
45
+ // Not preceded by a path/word character and not followed by `/`, `-` or `.ext`, so file names such
46
+ // as `round2.ts` or `phase1.sql` are not planning labels, while a sentence-final "Phase 2." is.
45
47
  const PLANNING_WORD_RE =
46
- /\b(?:phase|round|welle|wave|sprint)[- ]?\d+(?:\.\d+)*[a-z]?\b|\bbug-bash(?:[- ]\d+)?\b/gi;
48
+ /(?<![\w./-])(?:phase|round|welle|wave|sprint)[- ]?\d+(?:\.\d+)*[a-z]?(?![\w/-]|\.\w)|(?<![\w./-])bug-bash(?:[- ]\d+)?(?![\w/-]|\.\w)/gi;
47
49
  // Dotted plan labels such as S2.H1, T1.5c, B1.5. Case-sensitive and without V so
48
50
  // that version numbers (v1.2, V20.1) never count.
49
- const DOTTED_LABEL_RE = /\b[A-UW-Z]\d{1,2}(?:\.(?:\d+[a-z]?|[A-Z]\d+))+\b/g;
51
+ const DOTTED_LABEL_RE = /(?<![\w./-])[A-UW-Z]\d{1,2}(?:\.(?:\d+[a-z]?|[A-Z]\d+))+\b/g;
50
52
  const WORD_RE = /[\p{L}_$][\p{L}\p{N}_$'-]*/gu;
51
53
  const MIN_EXPLANATORY_WORD_LENGTH = 3;
52
54
 
@@ -1,3 +1,9 @@
1
+ // Comment-language ratchet: no new German comments in product code. No NLP —
2
+ // German markers are common function words or umlaut characters in the comment text.
3
+ // Baseline mode compares per-file counts against a frozen file (more than the
4
+ // baseline fails, fewer is allowed but does not rewrite it); `--touched` fails
5
+ // on any German comment that overlaps a line added since `--base`.
6
+
1
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
8
  import * as path from "node:path";
3
9
  import { scanCommentTrivia } from "./_lib/comment-trivia";
@@ -8,12 +14,6 @@ import { resolvePushRepoRoot } from "./_lib/push-root";
8
14
  import { resolveRepoRoots } from "./_lib/roots";
9
15
  import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
10
16
 
11
- // Comment-language ratchet: no new German comments in product code. No NLP —
12
- // German markers are common function words or umlaut characters in the comment text.
13
- // Baseline mode compares per-file counts against a frozen file (more than the
14
- // baseline fails, fewer is allowed but does not rewrite it); `--touched` fails
15
- // on any German comment that overlaps a line added since `--base`.
16
-
17
17
  export const COMMENT_LANG_FLAGS: readonly string[] = [
18
18
  "--touched",
19
19
  "--list",
@@ -206,10 +206,7 @@ function entityRegistrationOf(call: CallExpression): EntityRegistration | undefi
206
206
  return undefined;
207
207
  }
208
208
  const definition = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
209
- const nameProp = definition?.getProperty("name");
210
- if (!definition || nameProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
211
- const nameValue = nameProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
212
- if (nameValue?.getKind() !== SyntaxKind.StringLiteral) return undefined;
209
+ if (!definition?.getProperty("name")) return undefined;
213
210
  return {
214
211
  entityDid: spreadEntityDefinitionId(definition),
215
212
  overrideTableDid: entityTableOverrideId(definition),
@@ -601,7 +598,7 @@ function deriveCatalogs(
601
598
  }
602
599
 
603
600
  const REWRITE_CATALOGS_COMMAND =
604
- 'bun packages/guards/src/run-guards.ts --write-baseline --guard="Direct-Entity-Writes Guard"';
601
+ 'bunx kumiko-guards guards --write-baseline --guard="Direct-Entity-Writes Guard"';
605
602
 
606
603
  // Published packages whose catalog exists on disk although they define no ES table any more.
607
604
  function publishedRootsWithCatalog(files: readonly SourceFile[]): Set<string> {
@@ -670,6 +667,13 @@ function writeCatalogs(files: readonly SourceFile[], esTables: ReadonlySet<Table
670
667
  }
671
668
  }
672
669
 
670
+ const MAX_RECEIVER_TEXT_LENGTH = 60;
671
+
672
+ // Function-form receivers are arbitrary expressions; keep the message on one greppable line.
673
+ function compactReceiverText(node: Node): string {
674
+ return node.getText().replace(/\s+/g, "").slice(0, MAX_RECEIVER_TEXT_LENGTH);
675
+ }
676
+
673
677
  export function scanDirectWrites(
674
678
  sf: SourceFile,
675
679
  esTables: ReadonlySet<TableId>,
@@ -706,7 +710,7 @@ export function scanDirectWrites(
706
710
  if (isInsideProjectionApply(call)) continue;
707
711
  out.push({
708
712
  line: call.getStartLineNumber(),
709
- receiver: w.receiver.getText(),
713
+ receiver: compactReceiverText(w.receiver),
710
714
  op: w.op,
711
715
  table: w.tableArg.getText(),
712
716
  snippet: call.getText().slice(0, 120),
@@ -717,7 +721,7 @@ export function scanDirectWrites(
717
721
 
718
722
  out.push({
719
723
  line: call.getStartLineNumber(),
720
- receiver: w.receiver.getText(),
724
+ receiver: compactReceiverText(w.receiver),
721
725
  op: w.op,
722
726
  table: w.tableArg.getText(),
723
727
  snippet: call.getText().slice(0, 120),
@@ -727,6 +731,13 @@ export function scanDirectWrites(
727
731
  return out;
728
732
  }
729
733
 
734
+ type DirectWriteViolation = {
735
+ file: string;
736
+ line: number;
737
+ message: string;
738
+ neverFrozen?: boolean;
739
+ };
740
+
730
741
  export const guard: AstGuard = {
731
742
  name: "Direct-Entity-Writes Guard",
732
743
  scan: SCAN,
@@ -738,40 +749,18 @@ export const guard: AstGuard = {
738
749
  // Merge all collections BEFORE the empty-set check — otherwise a repo that only uses r.entity
739
750
  // would skip every scan: the canary fires falsely and writes on entity tables go undetected.
740
751
  const esTables = collectScopeEsTables(files);
741
- if (esTables.size === 0) {
742
- // An empty set means either the repo has no event store at all, or the
743
- // scan lost the ES definitions while table writes are still present.
744
- if (!hasAnyTableWriteOrDeclaration(files)) return { violations: [] };
745
- return {
746
- violations: [
747
- {
748
- file: "<scan>",
749
- line: 0,
750
- message:
751
- "BLOCKED: guard found table writes or table declarations but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
752
- },
753
- ],
754
- };
755
- }
756
-
757
- const violations: Array<{
758
- file: string;
759
- line: number;
760
- message: string;
761
- neverFrozen?: boolean;
762
- }> = catalogViolations(files, esTables);
763
-
764
752
  const firstFile = files[0];
765
753
  const externalEsTables = firstFile
766
754
  ? createExternalEsTables(firstFile.getProject())
767
755
  : { has: () => false };
768
756
 
757
+ const writeViolations: DirectWriteViolation[] = [];
769
758
  for (const sf of files) {
770
759
  const file = sf.getFilePath();
771
760
  if (EXCLUDE.test(file)) continue;
772
761
 
773
762
  for (const hit of scanDirectWrites(sf, esTables, externalEsTables)) {
774
- violations.push({
763
+ writeViolations.push({
775
764
  file: path.relative(ROOT, file),
776
765
  line: hit.line,
777
766
  message: `[${hit.kind}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
@@ -779,6 +768,29 @@ export const guard: AstGuard = {
779
768
  }
780
769
  }
781
770
 
771
+ if (esTables.size === 0) {
772
+ // Writes on catalog tables of installed packages are real findings even without own ES tables.
773
+ if (writeViolations.length > 0) return { violations: writeViolations };
774
+ // An empty set means either the repo has no event store at all, or the
775
+ // scan lost the ES definitions while table writes are still present.
776
+ if (!hasAnyTableWriteOrDeclaration(files)) return { violations: [] };
777
+ return {
778
+ violations: [
779
+ {
780
+ file: "<scan>",
781
+ line: 0,
782
+ message:
783
+ "BLOCKED: guard found table writes or table declarations but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
784
+ },
785
+ ],
786
+ };
787
+ }
788
+
789
+ const violations: DirectWriteViolation[] = [
790
+ ...catalogViolations(files, esTables),
791
+ ...writeViolations,
792
+ ];
793
+
782
794
  return { violations };
783
795
  },
784
796
  writeBaseline(files) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * File Size Guard — production files above 2000 lines are frozen in
3
+ * File Size Guard — production files above 1200 lines are frozen in
4
4
  * `.kumiko-file-size-baseline.json` (key = file, value = line count) and may
5
5
  * not grow; a new file above the threshold is a violation. Without a baseline
6
6
  * file (consumer repos) it only warns.
@@ -31,7 +31,7 @@ const SCAN: ScanSpec = {
31
31
  frameworkWithin: ["packages/*/src/**"],
32
32
  };
33
33
 
34
- export const MAX_FILE_LINES = 2000;
34
+ export const MAX_FILE_LINES = 1200;
35
35
 
36
36
  const EXCLUDE =
37
37
  /(__tests__|\.test\.(ts|tsx)$|\.integration\.(ts|tsx)$|\.d\.(ts|tsx)$|\.generated\.(ts|tsx)$|\/generated\/|\/locale-[^/]+\/src\/strings\.ts$)/;
@@ -14,7 +14,8 @@
14
14
  * Exit 1 on violations, 0 when clean.
15
15
  */
16
16
 
17
- import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
17
+ import { dirname, resolve } from "node:path";
18
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
18
19
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
19
20
 
20
21
  const SCAN: ScanSpec = {
@@ -23,7 +24,7 @@ const SCAN: ScanSpec = {
23
24
  };
24
25
 
25
26
  const EXCLUDE = /(\.d\.ts$|\/fixtures\/)/;
26
- const RELATIVE_JS_SPECIFIER = /^\.{1,2}\/.*\.jsx?$/;
27
+ const RELATIVE_JS_SPECIFIER = /^(\.{1,2}\/.*)\.(jsx?)$/;
27
28
  // A published bin that imports its own build output (`../dist/cli.js`) has no
28
29
  // `.ts` sibling to name; the `.js` there is the only file that exists.
29
30
  const BUILD_OUTPUT_SEGMENT = /(^|\/)dist\//;
@@ -33,17 +34,34 @@ interface Finding {
33
34
  readonly specifier: string;
34
35
  }
35
36
 
36
- function relativeJsSpecifier(node: Node | undefined): string | undefined {
37
- const literal = node?.asKind(SyntaxKind.StringLiteral);
38
- const value = literal?.getLiteralValue();
39
- if (value === undefined || !RELATIVE_JS_SPECIFIER.test(value)) return undefined;
40
- return BUILD_OUTPUT_SEGMENT.test(value) ? undefined : value;
37
+ // Mirrors the import-extensions codemod: only a specifier with a `.ts`/`.tsx` sibling is fixable.
38
+ // A genuine `.js` file (generated or vendored) has nothing to rename to and must not fail the guard.
39
+ function hasSourceSibling(sf: SourceFile, base: string, extension: string): boolean {
40
+ const candidates = extension === "jsx" ? ["tsx"] : ["ts", "tsx"];
41
+ const project = sf.getProject();
42
+ return candidates.some((candidate) => {
43
+ const path = resolve(dirname(sf.getFilePath()), `${base}.${candidate}`);
44
+ return (
45
+ project.getSourceFile(path) !== undefined || project.getFileSystem().fileExistsSync(path)
46
+ );
47
+ });
48
+ }
49
+
50
+ function relativeJsSpecifier(sf: SourceFile, node: Node | undefined): string | undefined {
51
+ const value =
52
+ node !== undefined && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node))
53
+ ? node.getLiteralValue()
54
+ : undefined;
55
+ if (value === undefined || BUILD_OUTPUT_SEGMENT.test(value)) return undefined;
56
+ const [, base = "", extension = ""] = RELATIVE_JS_SPECIFIER.exec(value) ?? [];
57
+ if (base === "" || !hasSourceSibling(sf, base, extension)) return undefined;
58
+ return value;
41
59
  }
42
60
 
43
61
  function findJsSpecifiers(sf: SourceFile): Finding[] {
44
62
  const findings: Finding[] = [];
45
63
  const add = (node: Node | undefined): void => {
46
- const specifier = relativeJsSpecifier(node);
64
+ const specifier = relativeJsSpecifier(sf, node);
47
65
  if (node !== undefined && specifier !== undefined) {
48
66
  findings.push({ line: node.getStartLineNumber(), specifier });
49
67
  }
@@ -51,8 +69,15 @@ function findJsSpecifiers(sf: SourceFile): Finding[] {
51
69
  for (const declaration of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
52
70
  add(declaration.getModuleSpecifier());
53
71
  }
72
+ for (const importType of sf.getDescendantsOfKind(SyntaxKind.ImportType)) {
73
+ const argument = importType.getArgument();
74
+ if (Node.isLiteralTypeNode(argument)) add(argument.getLiteral());
75
+ }
54
76
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
55
- if (call.getExpression().getKind() === SyntaxKind.ImportKeyword) add(call.getArguments()[0]);
77
+ const callee = call.getExpression();
78
+ if (callee.getKind() === SyntaxKind.ImportKeyword || callee.getText() === "mock.module") {
79
+ add(call.getArguments()[0]);
80
+ }
56
81
  }
57
82
  return findings;
58
83
  }
@@ -101,13 +101,13 @@ const ALLOWLIST = [
101
101
  // Number.isNaN(d.getTime()) as a validity check.
102
102
  /packages\/framework\/src\/pipeline\/entity-cache\.ts$/,
103
103
  // Event-store insertSubsequentEvent: raw SQL row.created_at can be Date|string
104
- // (postgres-js driver-config dependent), normalised to Temporal.Instant via
104
+ // (driver-config dependent), normalised to Temporal.Instant via
105
105
  // .getTime() bridge.
106
106
  /packages\/framework\/src\/event-store\/event-store\.ts$/,
107
107
  // HTTP Last-Modified/ETag header: Bun/Node Response API expects a native
108
108
  // Date object from fs.Stats.mtimeMs — wire boundary, not a Temporal format.
109
109
  /packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
110
- // postgres-js timestamptz columns arrive as native Date (driver wire format,
110
+ // timestamptz columns arrive as native Date (driver wire format,
111
111
  // same reason as packages/framework/src/db/ above).
112
112
  /packages\/publish\/src\/rollback\.ts$/,
113
113
  ];
@@ -90,7 +90,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
90
90
  // Framework: migrations/schema tooling, DB codegen, boot validator —
91
91
  // CLI/build time, no request input.
92
92
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
93
- { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli\.ts$/ },
93
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli(\.ts|\/[^/]+\.ts)$/ },
94
94
  // same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
95
95
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli\.ts$/ },
96
96
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
@@ -115,6 +115,12 @@ const ALLOWLIST: readonly AllowEntry[] = [
115
115
  repo: "kumiko-framework",
116
116
  pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
117
117
  },
118
+ // Package lookup for the build and the dev server: existence check for
119
+ // node_modules/<pkg>/package.json above a directory, no request input as a path.
120
+ {
121
+ repo: "kumiko-framework",
122
+ pattern: /^packages\/server-runtime\/src\/resolve-installed-package\.ts$/,
123
+ },
118
124
 
119
125
  // create-kumiko-app: Scaffolding-CLI.
120
126
  { repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
@@ -1,11 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- import { Project } from "ts-morph";
6
- import { cliFlagsError, type GuardViolation, reportResults } from "./_lib/guard-kit";
7
- import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
8
-
9
1
  // Public exports of packages/framework and packages/bundled-features must not
10
2
  // disappear (rename/removal) without a breaking changelog entry.
11
3
  //
@@ -30,6 +22,14 @@ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
30
22
  // has ONE changelog (src/changes.json); packages/bundled-features has one PER
31
23
  // FEATURE (src/<feature>/changes.json, feature = first export-key segment).
32
24
 
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+ import { pathToFileURL } from "node:url";
29
+ import { Project } from "ts-morph";
30
+ import { cliFlagsError, type GuardViolation, reportResults } from "./_lib/guard-kit";
31
+ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
32
+
33
33
  export const PUBLIC_EXPORTS_CHANGELOG_FLAGS: readonly string[] = [
34
34
  "--write-baseline",
35
35
  "--no-baseline",
@@ -11,6 +11,9 @@
11
11
  * emits SVG strings and the live dispatcher is a fetch client, so the symbol and JSX-tag rules would be noise there. They stay free
12
12
  * of react-dom, jsdom and any import of `src/web`.
13
13
  *
14
+ * `headless` additionally may not import react, the renderer root or anything outside its own
15
+ * folder (server/mail bundles import it); the shared layer may not import `live`.
16
+ *
14
17
  * Deliberately simple: a regex scan over the source text. False positives
15
18
  * (e.g. "window" in a string literal) are possible but rare — the guard is a
16
19
  * maintenance alarm, not a semantic proof.
@@ -41,6 +44,24 @@ const FORBIDDEN_IMPORTS = [
41
44
  /from ["']@cosmicdrift\/kumiko-dispatcher-live["']/,
42
45
  ];
43
46
 
47
+ // `headless` is consumed server-side (prod bundle, mail HTML): it must stay free of React and of
48
+ // the shared layer, so it may only import itself and third-party packages other than react.
49
+ const HEADLESS_FORBIDDEN_IMPORTS = [
50
+ /from ["']react(?:\/.*)?["']/,
51
+ /from ["']@cosmicdrift\/kumiko-renderer(?:\/(?!headless(?:["'/]))[^"']*)?["']/,
52
+ ];
53
+
54
+ // The fetch/document.cookie dispatcher client belongs to consumers of `live`, not the shared layer.
55
+ const SHARED_FORBIDDEN_IMPORTS = [
56
+ /from ["'](?:\.\.?\/)+live(?:\/.*)?["']/,
57
+ /from ["']@cosmicdrift\/kumiko-renderer\/live(?:\/.*)?["']/,
58
+ ];
59
+
60
+ // dev-console.ts is a dependency-free console wrapper, safe in server bundles.
61
+ const HEADLESS_ALLOWED_LEAF = /(?:^|\/)dev-console(?:\.ts)?$/;
62
+
63
+ const RELATIVE_SPECIFIER = /from ["'](\.\.?\/[^"']*)["']/;
64
+
44
65
  // Runtime symbols that mean DOM/browser. `\b` prevents substring matches
45
66
  // (e.g. "origin" not matched as "origin.foo"). This is a coarse guard — a
46
67
  // ts-morph-based check would be more precise, but not worth the effort here.
@@ -82,6 +103,34 @@ type Violation = {
82
103
  readonly excerpt: string;
83
104
  };
84
105
 
106
+ const RENDERER_SRC = `${path.sep}packages${path.sep}renderer${path.sep}src${path.sep}`;
107
+
108
+ // Absolute path of `packages/renderer/src/<dir>` containing `file`, or "" when it is elsewhere.
109
+ function rendererSrcSubdir(file: string, dir: string): string {
110
+ const marker = `${RENDERER_SRC}${dir}${path.sep}`;
111
+ const index = file.indexOf(marker);
112
+ return index === -1 ? "" : file.slice(0, index + marker.length - 1);
113
+ }
114
+
115
+ function isInside(dir: string, target: string): boolean {
116
+ return target === dir || target.startsWith(dir + path.sep);
117
+ }
118
+
119
+ function escapesHeadless(file: string, line: string): boolean {
120
+ const headlessDir = rendererSrcSubdir(file, "headless");
121
+ const specifier = RELATIVE_SPECIFIER.exec(line)?.[1];
122
+ if (headlessDir === "" || specifier === undefined) return false;
123
+ if (HEADLESS_ALLOWED_LEAF.test(specifier)) return false;
124
+ return !isInside(headlessDir, path.resolve(path.dirname(file), specifier));
125
+ }
126
+
127
+ function isSharedLayer(file: string): boolean {
128
+ return (
129
+ file.includes(RENDERER_SRC) &&
130
+ ["headless", "live", "web"].every((dir) => rendererSrcSubdir(file, dir) === "")
131
+ );
132
+ }
133
+
85
134
  function walk(dir: string, skippedDirs: readonly string[] = []): string[] {
86
135
  const out: string[] = [];
87
136
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -111,15 +160,18 @@ export function findViolations(file: string, root: string, importRulesOnly = fal
111
160
  const trimmed = line.trim();
112
161
  if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
113
162
 
114
- for (const pattern of FORBIDDEN_IMPORTS) {
115
- if (pattern.test(line)) {
116
- violations.push({
117
- file: path.relative(root, file),
118
- line: i + 1,
119
- rule: "forbidden-import",
120
- excerpt: trimmed,
121
- });
122
- }
163
+ const importPatterns = [
164
+ ...FORBIDDEN_IMPORTS,
165
+ ...(rendererSrcSubdir(file, "headless") !== "" ? HEADLESS_FORBIDDEN_IMPORTS : []),
166
+ ...(isSharedLayer(file) ? SHARED_FORBIDDEN_IMPORTS : []),
167
+ ];
168
+ if (importPatterns.some((pattern) => pattern.test(line)) || escapesHeadless(file, line)) {
169
+ violations.push({
170
+ file: path.relative(root, file),
171
+ line: i + 1,
172
+ rule: "forbidden-import",
173
+ excerpt: trimmed,
174
+ });
123
175
  }
124
176
  if (importRulesOnly) continue;
125
177
  for (const pattern of FORBIDDEN_SYMBOLS) {
@@ -52,6 +52,8 @@ const ALLOWLIST = [
52
52
  /^packages\/framework\/src\/event-store\/index\.ts$/,
53
53
  /^packages\/framework\/src\/event-store\/tenant-db-append\.ts$/,
54
54
  /^packages\/bundled-features\/src\/cap-counter\/cap-reservation\.ts$/,
55
+ // Catch-up of a missing terminal extract event in the caller tenant's own stream; JobContext has no appendEvent.
56
+ /^packages\/bundled-features\/src\/document-ingest-foundation\/append-missing-extract-event\.ts$/,
55
57
  /^packages\/bundled-features\/src\/user-data-rights-defaults\/hooks\/delivery-attempt\.userdata-hook\.ts$/,
56
58
  /^packages\/framework\/src\/db\/tenant-db\.ts$/,
57
59
  /^packages\/framework\/src\/db\/index\.ts$/,
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: tests seed event-sourced state through the event store, not through
4
+ * hand-written SQL. Flags, in test files, string/template literals with
5
+ * (a) INSERT INTO / UPDATE against kumiko_events (use append / runSeedWritesAt)
6
+ * (b) CREATE TABLE ... read_tenant_memberships (use unsafeCreateEntityTable with
7
+ * tenantMembershipEntity and seedRow, so the schema cannot drift from the entity)
8
+ *
9
+ * Usage:
10
+ * bun packages/guards/src/guard-test-es-fixtures.ts
11
+ */
12
+ import type { SourceFile } from "ts-morph";
13
+ import { SyntaxKind } from "ts-morph";
14
+ import {
15
+ type AstGuard,
16
+ type GuardViolation,
17
+ relFromRepoRoot,
18
+ runStandalone,
19
+ type ScanSpec,
20
+ } from "./_lib/guard-kit";
21
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
22
+
23
+ const SCAN: ScanSpec = { scope: "tests", extensions: ["ts"] };
24
+
25
+ // Bulk seeding of thousands of events through append() would take minutes; these
26
+ // perf tests measure the read path on a finished stream, not the seed phase.
27
+ // The concurrent-commit ordering tests hold a transaction open and need explicit event
28
+ // ids/versions, which append() does not expose.
29
+ // The guards' own tests carry the forbidden SQL as fixture strings.
30
+ const ALLOWLIST: readonly RegExp[] = [
31
+ /^packages\/framework\/src\/event-store\/__tests__\/perf\.integration\.test\.ts$/,
32
+ /^packages\/framework\/src\/pipeline\/__tests__\/perf-rebuild\.integration\.test\.ts$/,
33
+ /^packages\/framework\/src\/pipeline\/__tests__\/event-dispatcher-commit-order\.integration\.test\.ts$/,
34
+ /^packages\/framework\/src\/pipeline\/__tests__\/event-dispatcher-start-from\.integration\.test\.ts$/,
35
+ /^packages\/framework\/src\/pipeline\/__tests__\/msp-rebuild\.integration\.test\.ts$/,
36
+ /^packages\/framework\/src\/pipeline\/__tests__\/projection-rebuild\.integration\.test\.ts$/,
37
+ /^packages\/guards\/src\/__tests__\//,
38
+ ];
39
+
40
+ const EVENT_WRITE_RE = /\b(INSERT\s+INTO|UPDATE)\s+["`]?kumiko_events\b/i;
41
+ const MEMBERSHIP_DDL_RE = /\bCREATE\s+TABLE\b[^;]*?\bread_tenant_memberships\b/i;
42
+
43
+ const LITERAL_KINDS = [
44
+ SyntaxKind.StringLiteral,
45
+ SyntaxKind.NoSubstitutionTemplateLiteral,
46
+ SyntaxKind.TemplateExpression,
47
+ ];
48
+
49
+ function violationMessage(text: string): string | undefined {
50
+ const write = EVENT_WRITE_RE.exec(text);
51
+ if (write)
52
+ return `raw ${write[0].replace(/\s+/g, " ")} in a test: seed via append() or runSeedWritesAt()`;
53
+ if (MEMBERSHIP_DDL_RE.test(text)) {
54
+ return "hand-written read_tenant_memberships DDL in a test: use unsafeCreateEntityTable(db, tenantMembershipEntity)";
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViolation[] {
60
+ const file = relFromRepoRoot(sourceFile.getFilePath(), roots);
61
+ if (ALLOWLIST.some((re) => re.test(file))) return [];
62
+ const violations: GuardViolation[] = [];
63
+ for (const kind of LITERAL_KINDS) {
64
+ for (const node of sourceFile.getDescendantsOfKind(kind)) {
65
+ const message = violationMessage(node.getText());
66
+ if (message) violations.push({ file, line: node.getStartLineNumber(), message });
67
+ }
68
+ }
69
+ return violations;
70
+ }
71
+
72
+ export const guard: AstGuard = {
73
+ name: "Test-ES-Fixtures Guard",
74
+ scan: SCAN,
75
+ hint:
76
+ "Seed events with append() (runSeedWritesAt for backdating) and membership rows with " +
77
+ "seedRow on a table made by unsafeCreateEntityTable; bulk perf seeds belong in the guard allowlist with a Why.",
78
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
79
+ return { violations: files.flatMap((sf) => collect(sf, roots)) };
80
+ },
81
+ };
82
+
83
+ if (import.meta.main) runStandalone(guard);
@@ -32,7 +32,7 @@
32
32
  * Consumer (via the published `kumiko-guards` CLI):
33
33
  * kumiko-guards guards --guard=test-timeouts
34
34
  */
35
- import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
35
+ import { type CallExpression, Node, type SourceFile, SyntaxKind, type Type } from "ts-morph";
36
36
  import {
37
37
  type AstGuard,
38
38
  type GuardOutcome,
@@ -182,12 +182,20 @@ function declarationBaseCallee(callee: Node): Node {
182
182
  }
183
183
 
184
184
  // An identifier can be the Playwright test body (`test(title, details, body)`) or an options
185
- // variable like `{ retry: 2 }`, so it only counts when its type is a number or carries `timeout`.
185
+ // variable like `{ retry: 2 }`, so only its type decides. Every other non-function expression
186
+ // (conditional, member access, call) is treated as a timeout so a union or untyped value cannot hide one.
186
187
  function isTimeoutArgument(arg: Node): boolean {
187
188
  if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
188
189
  if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) return false;
190
+ if (!Node.isIdentifier(arg)) return true;
189
191
  const type = arg.getType();
190
- return type.isNumber() || type.isNumberLiteral() || type.getProperty("timeout") !== undefined;
192
+ if (type.isAny() || type.isUnknown()) return true;
193
+ const carriesNumber = (t: Type): boolean => t.isNumber() || t.isNumberLiteral();
194
+ return (
195
+ carriesNumber(type) ||
196
+ (type.isUnion() && type.getUnionTypes().some(carriesNumber)) ||
197
+ type.getProperty("timeout") !== undefined
198
+ );
191
199
  }
192
200
 
193
201
  function declarationTimeoutReason(