@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.
- package/README.md +3 -2
- package/package.json +3 -3
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/guards-doc.ts +4 -1
- package/src/_lib/roots.ts +9 -5
- package/src/_lib/run-slots.ts +39 -10
- package/src/_lib/security-audit.ts +15 -8
- package/src/changes.json +50 -0
- package/src/check-complexity.ts +86 -27
- package/src/check-licenses.ts +23 -6
- package/src/check-security.ts +2 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-app-feature-structure.ts +9 -7
- package/src/guard-comment-issue-ref-only.ts +4 -2
- package/src/guard-comment-lang.ts +6 -6
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-file-size.ts +2 -2
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-date-api.ts +2 -2
- package/src/guard-no-direct-fs.ts +7 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -0
- package/src/guard-test-es-fixtures.ts +83 -0
- package/src/guard-test-timeouts.ts +11 -3
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/pre-push.sh +37 -9
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/runtime-isolation-classify.ts +2 -1
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
? "
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/guard-file-size.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* File Size Guard — production files above
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/guard-no-date-api.ts
CHANGED
|
@@ -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
|
-
// (
|
|
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
|
-
//
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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(
|