@cosmicdrift/kumiko-guards 0.387.0 → 0.388.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/_lib/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 +38 -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-direct-fs.ts +1 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-test-timeouts.ts +11 -3
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/pre-push.sh +22 -6
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +9 -4
- package/src/runtime-isolation-classify.ts +2 -1
|
@@ -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
|
}
|
|
@@ -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$/ },
|
|
@@ -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) {
|
|
@@ -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(
|
|
@@ -335,16 +335,51 @@ function extractMarkerReason(node: FnNode): string | null {
|
|
|
335
335
|
return null;
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
// A typed lookup table (`const T: Record<K, Fn> = {…}` or `{…} satisfies …`) fixes one
|
|
339
|
+
// signature per slot; a one-call entry adapts that signature, so inlining it would mean
|
|
340
|
+
// dropping the table, not the wrapper.
|
|
341
|
+
function isTypedTableLiteral(literal: Node): boolean {
|
|
342
|
+
let parent = literal.getParent();
|
|
343
|
+
while (parent?.isKind(SyntaxKind.AsExpression)) parent = parent.getParent();
|
|
344
|
+
if (parent?.isKind(SyntaxKind.SatisfiesExpression)) return true;
|
|
345
|
+
return parent?.isKind(SyntaxKind.VariableDeclaration) === true && !!parent.getTypeNode();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collectTypedTableValueNames(sf: SourceFile): Set<string> {
|
|
349
|
+
const names = new Set<string>();
|
|
350
|
+
for (const literal of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
351
|
+
if (!isTypedTableLiteral(literal)) continue;
|
|
352
|
+
for (const property of literal.getProperties()) {
|
|
353
|
+
if (property.isKind(SyntaxKind.ShorthandPropertyAssignment)) names.add(property.getName());
|
|
354
|
+
if (!property.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
355
|
+
const value = property.getInitializer();
|
|
356
|
+
if (value?.isKind(SyntaxKind.Identifier)) names.add(value.getText());
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return names;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isInlineTypedTableValue(node: FnNode): boolean {
|
|
363
|
+
const property = node.getParent();
|
|
364
|
+
const literal = property?.isKind(SyntaxKind.PropertyAssignment)
|
|
365
|
+
? property.getParent()
|
|
366
|
+
: undefined;
|
|
367
|
+
return literal !== undefined && isTypedTableLiteral(literal);
|
|
368
|
+
}
|
|
369
|
+
|
|
338
370
|
// ── Scan ──────────────────────────────────────────────────────────────────
|
|
339
371
|
|
|
340
372
|
export function collectFindings(sf: SourceFile): Finding[] {
|
|
341
373
|
const file = path.relative(ROOT, sf.getFilePath());
|
|
342
374
|
const findings: Finding[] = [];
|
|
375
|
+
const typedTableValueNames = collectTypedTableValueNames(sf);
|
|
343
376
|
|
|
344
377
|
const check = (node: FnNode) => {
|
|
345
378
|
const fnName = getFnName(node);
|
|
346
379
|
// skip: anonymous functions cannot be reported by name
|
|
347
380
|
if (!fnName) return;
|
|
381
|
+
// skip: a typed table slot, see isTypedTableLiteral
|
|
382
|
+
if (typedTableValueNames.has(fnName) || isInlineTypedTableValue(node)) return;
|
|
348
383
|
|
|
349
384
|
const callee = getSingleDirectCallee(node);
|
|
350
385
|
// skip: not a single-callee wrapper
|
package/src/pre-push.sh
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
# sub-repo's own package.json `test` script — refuses the push if that
|
|
19
19
|
# script is missing.
|
|
20
20
|
#
|
|
21
|
-
# Before the checks below, the hook runs a gitleaks scan of origin/main
|
|
22
|
-
# when the binary is installed.
|
|
21
|
+
# Before the checks below, the hook runs a gitleaks scan of origin/main..<pushed sha>
|
|
22
|
+
# for every pushed ref when the binary is installed.
|
|
23
23
|
#
|
|
24
24
|
# Extension point: a tracked, executable scripts/pre-push-extra.sh runs
|
|
25
25
|
# before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
|
|
@@ -123,11 +123,27 @@ if ! command -v gitleaks >/dev/null 2>&1; then
|
|
|
123
123
|
elif ! git -C "$REPO_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
|
124
124
|
echo "[pre-push] origin/main not found — secret scan skipped" >&2
|
|
125
125
|
else
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
# git passes "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref on stdin;
|
|
127
|
+
# scan those tips (not HEAD), skip deletes. Without stdin (direct call) fall back to HEAD.
|
|
128
|
+
SCAN_TIPS=""
|
|
129
|
+
PUSH_LINES=0
|
|
130
|
+
if [ ! -t 0 ]; then
|
|
131
|
+
while read -r _local_ref LOCAL_SHA _remote_ref _remote_sha; do
|
|
132
|
+
[ -n "$LOCAL_SHA" ] || continue
|
|
133
|
+
PUSH_LINES=$((PUSH_LINES + 1))
|
|
134
|
+
case "$LOCAL_SHA" in *[!0]*) SCAN_TIPS="$SCAN_TIPS $LOCAL_SHA" ;; esac
|
|
135
|
+
done
|
|
136
|
+
fi
|
|
137
|
+
if [ "$PUSH_LINES" -eq 0 ]; then
|
|
138
|
+
SCAN_TIPS=" HEAD"
|
|
130
139
|
fi
|
|
140
|
+
for SCAN_TIP in $SCAN_TIPS; do
|
|
141
|
+
echo "[pre-push] gitleaks git origin/main..$SCAN_TIP"
|
|
142
|
+
if ! (cd "$REPO_ROOT" && gitleaks git --no-banner --redact --log-opts="origin/main..$SCAN_TIP" .); then
|
|
143
|
+
echo "[pre-push] gitleaks found secrets — refusing push" >&2
|
|
144
|
+
exit 1
|
|
145
|
+
fi
|
|
146
|
+
done
|
|
131
147
|
fi
|
|
132
148
|
|
|
133
149
|
# infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
|
|
@@ -39,20 +39,24 @@ function propertyName(node: Node): string | undefined {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function objectProperty(object: ObjectLiteralExpression, name: string): Node | undefined {
|
|
42
|
+
let found: Node | undefined;
|
|
43
|
+
let opaqueAfterFound = false;
|
|
42
44
|
for (const property of object.getProperties()) {
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
|
|
52
|
-
return property.getInitializerOrThrow();
|
|
45
|
+
if (property.isKind(SyntaxKind.SpreadAssignment)) {
|
|
46
|
+
opaqueAfterFound = true;
|
|
47
|
+
} else if (property.isKind(SyntaxKind.ShorthandPropertyAssignment)) {
|
|
48
|
+
if (property.getName() === name) opaqueAfterFound = true;
|
|
49
|
+
} else if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
|
|
50
|
+
found = property.getInitializerOrThrow();
|
|
51
|
+
opaqueAfterFound = false;
|
|
53
52
|
}
|
|
54
53
|
}
|
|
55
|
-
|
|
54
|
+
if (opaqueAfterFound) {
|
|
55
|
+
throw new StaticConfigError(
|
|
56
|
+
`${name}: spread and shorthand properties cannot be read statically`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
function numericLiteral(node: Node | undefined, path: string): number {
|
package/src/run-guards.ts
CHANGED
|
@@ -97,6 +97,8 @@ const GUARD_NAME_PREFIX = "--guard=";
|
|
|
97
97
|
|
|
98
98
|
export type RunGuardsCliDeps = {
|
|
99
99
|
readonly writeBaselineRoots?: readonly RepoRoot[];
|
|
100
|
+
readonly acquireSlot?: () => () => void;
|
|
101
|
+
readonly buildProject?: typeof buildSharedProject;
|
|
100
102
|
};
|
|
101
103
|
|
|
102
104
|
// Shared by the direct `bun run-guards.ts` invocation below and by the
|
|
@@ -115,7 +117,7 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
115
117
|
return 1;
|
|
116
118
|
}
|
|
117
119
|
if (flags.includes("--explain")) {
|
|
118
|
-
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
120
|
+
for (const line of explainGuards(GUARDS, (deps.buildProject ?? buildSharedProject)(GUARDS))) {
|
|
119
121
|
console.log(line);
|
|
120
122
|
}
|
|
121
123
|
return 0;
|
|
@@ -155,12 +157,15 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
155
157
|
);
|
|
156
158
|
return 0;
|
|
157
159
|
}
|
|
158
|
-
|
|
160
|
+
return runAllGuards(flags.includes("--strict-security-baseline"), deps);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runAllGuards(strictSecurityBaseline: boolean, deps: RunGuardsCliDeps): number {
|
|
159
164
|
const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
|
|
160
165
|
// Taken before the ts-morph project is built: that is where the memory goes.
|
|
161
|
-
const releaseSlot = acquireGuardRunSlot();
|
|
166
|
+
const releaseSlot = (deps.acquireSlot ?? acquireGuardRunSlot)();
|
|
162
167
|
try {
|
|
163
|
-
const project = buildSharedProject(guards);
|
|
168
|
+
const project = (deps.buildProject ?? buildSharedProject)(guards);
|
|
164
169
|
printGuardKitBanner(guards.length, project);
|
|
165
170
|
return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
|
|
166
171
|
} finally {
|