@cosmicdrift/kumiko-guards 0.386.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/LICENSE +21 -57
- package/package.json +4 -4
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/comment-trivia.ts +148 -0
- package/src/_lib/guard-kit.ts +2 -2
- 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/scan-scope.ts +1 -1
- package/src/_lib/security-audit.ts +15 -8
- package/src/_lib/sql-inventory.ts +2 -2
- package/src/changes.json +45 -0
- package/src/check-as-casts.ts +6 -7
- package/src/check-complexity.ts +86 -29
- package/src/check-licenses.ts +23 -6
- package/src/check-real-provider-isolation.ts +1 -1
- package/src/check-runtime-isolation.ts +2 -2
- package/src/check-security.ts +2 -1
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-admin-api.ts +5 -6
- package/src/guard-app-feature-structure.ts +9 -9
- package/src/guard-comment-issue-ref-only.ts +223 -0
- package/src/guard-comment-lang.ts +10 -141
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-direct-fetch.ts +3 -3
- package/src/guard-feature-integration-tests.ts +1 -1
- package/src/guard-file-size.ts +2 -2
- package/src/guard-i18n-locale-mount.ts +4 -4
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-i18n-ui-strings.ts +5 -5
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-custom-primitives.ts +2 -4
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-direct-process-env.ts +2 -2
- package/src/guard-no-inline-styles.ts +1 -1
- package/src/guard-no-raw-hooks.ts +0 -2
- package/src/guard-primitives-discipline.ts +1 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +1 -1
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -3
- package/src/guard-tailwind-scan-surface.ts +28 -29
- package/src/guard-tenant-escalation.ts +3 -3
- package/src/guard-test-template-drift.ts +1 -1
- package/src/guard-test-timeouts.ts +12 -4
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/guard-write-handler-qns.ts +1 -1
- package/src/pre-push.sh +22 -6
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +3 -2
|
@@ -1,6 +1,12 @@
|
|
|
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
|
-
import {
|
|
9
|
+
import { scanCommentTrivia } from "./_lib/comment-trivia";
|
|
4
10
|
import { gitEnv } from "./_lib/git-env";
|
|
5
11
|
import { ALL_REPO_KINDS, cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
|
|
6
12
|
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
@@ -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",
|
|
@@ -72,142 +72,11 @@ function germanCommentSite(
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Tokens that end an operand without being identifiers or keywords.
|
|
76
|
-
const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
77
|
-
ts.SyntaxKind.NumericLiteral,
|
|
78
|
-
ts.SyntaxKind.BigIntLiteral,
|
|
79
|
-
ts.SyntaxKind.StringLiteral,
|
|
80
|
-
ts.SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
81
|
-
ts.SyntaxKind.TemplateTail,
|
|
82
|
-
ts.SyntaxKind.RegularExpressionLiteral,
|
|
83
|
-
ts.SyntaxKind.CloseParenToken,
|
|
84
|
-
ts.SyntaxKind.CloseBracketToken,
|
|
85
|
-
ts.SyntaxKind.CloseBraceToken,
|
|
86
|
-
ts.SyntaxKind.PlusPlusToken,
|
|
87
|
-
ts.SyntaxKind.MinusMinusToken,
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
|
-
// Reserved words after which an expression (so possibly a regex) starts.
|
|
91
|
-
const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
92
|
-
ts.SyntaxKind.ReturnKeyword,
|
|
93
|
-
ts.SyntaxKind.TypeOfKeyword,
|
|
94
|
-
ts.SyntaxKind.InstanceOfKeyword,
|
|
95
|
-
ts.SyntaxKind.InKeyword,
|
|
96
|
-
ts.SyntaxKind.CaseKeyword,
|
|
97
|
-
ts.SyntaxKind.VoidKeyword,
|
|
98
|
-
ts.SyntaxKind.DeleteKeyword,
|
|
99
|
-
ts.SyntaxKind.ThrowKeyword,
|
|
100
|
-
ts.SyntaxKind.NewKeyword,
|
|
101
|
-
ts.SyntaxKind.YieldKeyword,
|
|
102
|
-
ts.SyntaxKind.AwaitKeyword,
|
|
103
|
-
ts.SyntaxKind.ElseKeyword,
|
|
104
|
-
ts.SyntaxKind.DoKeyword,
|
|
105
|
-
ts.SyntaxKind.ExtendsKeyword,
|
|
106
|
-
ts.SyntaxKind.DefaultKeyword,
|
|
107
|
-
]);
|
|
108
|
-
|
|
109
|
-
function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
|
|
110
|
-
return (
|
|
111
|
-
kind === ts.SyntaxKind.Identifier ||
|
|
112
|
-
(kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// After an operand end a `/` is division, not the start of a regex literal.
|
|
117
|
-
// Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
|
|
118
|
-
// valid member and variable names, so every non-introducing keyword counts. A `!`
|
|
119
|
-
// is the non-null postfix only when the token before it ended an operand.
|
|
120
|
-
function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
|
|
121
|
-
if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
|
|
122
|
-
if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
|
|
123
|
-
return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
127
|
-
ts.SyntaxKind.SingleLineCommentTrivia,
|
|
128
|
-
ts.SyntaxKind.MultiLineCommentTrivia,
|
|
129
|
-
ts.SyntaxKind.WhitespaceTrivia,
|
|
130
|
-
ts.SyntaxKind.NewLineTrivia,
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
|
-
// The raw scanner always yields SlashToken for `/`; without the rescan a regex
|
|
134
|
-
// body like /\{/ would shift braceDepth and end a template early. After an
|
|
135
|
-
// operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
|
|
136
|
-
function rescanSlashAsRegex(
|
|
137
|
-
scanner: ts.Scanner,
|
|
138
|
-
kind: ts.SyntaxKind,
|
|
139
|
-
previousEndedOperand: boolean,
|
|
140
|
-
): ts.SyntaxKind {
|
|
141
|
-
if (previousEndedOperand) return kind;
|
|
142
|
-
const reScanned = scanner.reScanSlashToken();
|
|
143
|
-
return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function closesTemplateSubstitution(
|
|
147
|
-
templateBraceDepths: readonly number[],
|
|
148
|
-
braceDepth: number,
|
|
149
|
-
): boolean {
|
|
150
|
-
return (
|
|
151
|
-
templateBraceDepths.length > 0 &&
|
|
152
|
-
braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Template substitutions can nest braces (object literals, arrow bodies, other
|
|
157
|
-
// templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
|
|
158
|
-
// never resumes template mode, so reScanTemplateToken() must run once brace
|
|
159
|
-
// depth returns to the level the substitution opened at.
|
|
160
75
|
export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
161
76
|
const sites: Site[] = [];
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
ts.LanguageVariant.Standard,
|
|
166
|
-
fullText,
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const templateBraceDepths: number[] = [];
|
|
170
|
-
let braceDepth = 0;
|
|
171
|
-
let previousEndedOperand = false;
|
|
172
|
-
|
|
173
|
-
let kind = scanner.scan();
|
|
174
|
-
while (kind !== ts.SyntaxKind.EndOfFileToken) {
|
|
175
|
-
switch (kind) {
|
|
176
|
-
case ts.SyntaxKind.SingleLineCommentTrivia:
|
|
177
|
-
case ts.SyntaxKind.MultiLineCommentTrivia: {
|
|
178
|
-
const site = germanCommentSite(
|
|
179
|
-
fullText,
|
|
180
|
-
file,
|
|
181
|
-
scanner.getTokenStart(),
|
|
182
|
-
scanner.getTokenText(),
|
|
183
|
-
);
|
|
184
|
-
if (site) sites.push(site);
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
case ts.SyntaxKind.SlashToken:
|
|
188
|
-
case ts.SyntaxKind.SlashEqualsToken:
|
|
189
|
-
kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
|
|
190
|
-
break;
|
|
191
|
-
case ts.SyntaxKind.TemplateHead:
|
|
192
|
-
case ts.SyntaxKind.TemplateMiddle:
|
|
193
|
-
templateBraceDepths.push(braceDepth);
|
|
194
|
-
break;
|
|
195
|
-
case ts.SyntaxKind.OpenBraceToken:
|
|
196
|
-
braceDepth++;
|
|
197
|
-
break;
|
|
198
|
-
case ts.SyntaxKind.CloseBraceToken:
|
|
199
|
-
if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
|
|
200
|
-
templateBraceDepths.pop();
|
|
201
|
-
kind = scanner.reScanTemplateToken(false);
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (braceDepth > 0) braceDepth--;
|
|
205
|
-
break;
|
|
206
|
-
default:
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
|
|
210
|
-
kind = scanner.scan();
|
|
77
|
+
for (const comment of scanCommentTrivia(fullText)) {
|
|
78
|
+
const site = germanCommentSite(fullText, file, comment.start, comment.text);
|
|
79
|
+
if (site) sites.push(site);
|
|
211
80
|
}
|
|
212
81
|
return sites;
|
|
213
82
|
}
|
|
@@ -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) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Guard: finds raw `fetch(` calls in server code outside the client-scoped
|
|
4
4
|
* exceptions.
|
|
5
5
|
*
|
|
6
|
-
* `egress(policy)` (@cosmicdrift/kumiko-framework/http
|
|
6
|
+
* `egress(policy)` (@cosmicdrift/kumiko-framework/http) is
|
|
7
7
|
* the single exported way for server code to speak outward — it enforces an
|
|
8
8
|
* SSRF policy (external/internal/tenant-supplied) at the call site instead
|
|
9
9
|
* of leaving that to convention. This guard fails on raw `fetch(` in server
|
|
@@ -45,7 +45,7 @@ const EXCLUDE =
|
|
|
45
45
|
/(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|(?:^|\/)web\/|(?:^|\/)public\/)/;
|
|
46
46
|
|
|
47
47
|
// Prefixed by RepoRoot.name (not kind): kind "app" is shared across
|
|
48
|
-
// unregistered repos
|
|
48
|
+
// unregistered repos.
|
|
49
49
|
|
|
50
50
|
// packages/framework/src/http/egress.ts IS the egress() implementation.
|
|
51
51
|
const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
|
|
@@ -59,7 +59,7 @@ interface Violation {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// `self` omitted: Worker globals live under web/public (excluded); a local
|
|
62
|
-
// `const self = this` binding was a systematic FP
|
|
62
|
+
// `const self = this` binding was a systematic FP.
|
|
63
63
|
const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
|
|
64
64
|
|
|
65
65
|
// The marker is a per-line skip for a security guard, so it only counts with a
|
|
@@ -115,7 +115,7 @@ function collectImportedFeatureIds(project: Project, roots: readonly RepoRoot[])
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Baseline from the
|
|
118
|
+
* Baseline from the measurement: features with no integration
|
|
119
119
|
* test reaching their directory at all — only covered (if at all) by unit
|
|
120
120
|
* tests (`feature.test.ts`). Pre-existing at the first sharp run, not
|
|
121
121
|
* introduced by this change. Backfilling is its own scope per feature, not
|
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$)/;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* guard-i18n-keys.ts only checks whether a used t() key is defined —
|
|
7
7
|
* and has no concept of a "mount point". A mount that never calls
|
|
8
8
|
* localeDeClient() (client) or localeDe() (server) slips through there
|
|
9
|
-
* completely
|
|
9
|
+
* completely.
|
|
10
10
|
*
|
|
11
11
|
* Scope gate: the package.json closest to the file must point at
|
|
12
12
|
* kumiko-locale-de (currently kumiko-studio, publicstatus, solon,
|
|
@@ -83,9 +83,9 @@ function gateFor(sf: SourceFile, cache: Map<string, Gate>): Gate | undefined {
|
|
|
83
83
|
return gate;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// localeDeClient()
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// localeDeClient() in clientFeatures is matched by call text; `{ de: ... }` also
|
|
87
|
+
// covers a raw <LocaleProvider fallbackBundles={[{ de: bundle }, ...]}> composition
|
|
88
|
+
// that does not use a localeDeClient() call.
|
|
89
89
|
function localeDeClientRef(node: Node): boolean {
|
|
90
90
|
if (node.isKind(SyntaxKind.Identifier) && node.getText() === "localeDeClient") return true;
|
|
91
91
|
if (node.isKind(SyntaxKind.CallExpression)) return localeDeClientRef(node.getExpression());
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard: locale bundle values must follow Mandant/Organización terminology
|
|
3
|
+
* Guard: locale bundle values must follow Mandant/Organización terminology.
|
|
4
4
|
*
|
|
5
5
|
* German UI copy always says "Mandant" (never "Tenant"/"Organisation"); Spanish, French
|
|
6
6
|
* and Italian always say "Organización"/"organisation"/"organizzazione" (never the
|
|
@@ -50,8 +50,8 @@ const RULES: Record<TerminologyLocale, LocaleRule> = {
|
|
|
50
50
|
de: {
|
|
51
51
|
preferred: "Mandant",
|
|
52
52
|
// Tenants? covers plural; Organisation(en|s)? without trailing \b so
|
|
53
|
-
// compounds like Organisations-ID / Organisationsstruktur still match
|
|
54
|
-
//
|
|
53
|
+
// compounds like Organisations-ID / Organisationsstruktur still match.
|
|
54
|
+
// TenantAdmin stays safe: \b after Tenant fails on 'A'.
|
|
55
55
|
forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/i],
|
|
56
56
|
},
|
|
57
57
|
es: {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// that the keys are defined — this guard closes the gap before that:
|
|
5
5
|
// strings that never became keys, e.g. "Loading tenants…").
|
|
6
6
|
//
|
|
7
|
-
// Part of App-Mounting 2.0
|
|
7
|
+
// Part of App-Mounting 2.0.
|
|
8
8
|
|
|
9
9
|
import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
10
10
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
@@ -13,7 +13,7 @@ import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
|
13
13
|
// Web/public code only — server code (handler error texts) goes through the
|
|
14
14
|
// error i18n pipeline and has its own guards.
|
|
15
15
|
//
|
|
16
|
-
// samples/ deliberately NOT scanned (tried
|
|
16
|
+
// samples/ deliberately NOT scanned (tried and rolled back): the
|
|
17
17
|
// showcase/gallery pages are dev docs about the framework API itself
|
|
18
18
|
// ("Form.title / toolbarTitle", "children — auto from schema.navs"), not
|
|
19
19
|
// app UI text a real user sees. 354 hits in the trial run, almost all such
|
|
@@ -21,7 +21,7 @@ import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
|
21
21
|
// r.translations definitions); this guard stays scoped to app repos.
|
|
22
22
|
//
|
|
23
23
|
// src/features/**/feature.ts covers feature.ts registrar calls (r.nav/r.screen
|
|
24
|
-
// object-literal arguments) —
|
|
24
|
+
// object-literal arguments) — those sit outside JSX and were a
|
|
25
25
|
// blind spot. Other server code (handlers/, lib/) has its own i18n pipeline
|
|
26
26
|
// and is filtered out below by requiring an r.*-call ancestor.
|
|
27
27
|
const SCAN: ScanSpec = {
|
|
@@ -131,7 +131,7 @@ function collectTernaryLogicText(node: Node): TextPiece[] {
|
|
|
131
131
|
return textPieces(node);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
//
|
|
134
|
+
// A hop out of a helper is only taken while that helper call is
|
|
135
135
|
// itself an argument of the next call — a call nested deeper (inside an object
|
|
136
136
|
// or array, e.g. `createTextField({ label })` under `createEntity({ fields })`)
|
|
137
137
|
// builds a value instead of forwarding the registrar's argument. The full chain
|
|
@@ -220,7 +220,7 @@ function checkRegistrarLabelProperties(sf: SourceFile, violations: GuardViolatio
|
|
|
220
220
|
if (!HUMAN_TEXT.test(value) || I18N_KEY_SHAPE.test(value)) continue;
|
|
221
221
|
const callChain = registrarCallChain(prop);
|
|
222
222
|
if (callChain === undefined) continue;
|
|
223
|
-
// The agent-doc slot
|
|
223
|
+
// The agent-doc slot is LLM metadata for the
|
|
224
224
|
// AI tool catalog: English by design, never rendered as UI text. The
|
|
225
225
|
// slot is the options object handed to a call on the registrar
|
|
226
226
|
// argument chain, so forwarding it through a `defineEntity*Handler`
|
|
@@ -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
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// exist in the framework (usePrimitives() resp. widgets/) — app rebuilds
|
|
5
5
|
// drift visually and cost every migration twice.
|
|
6
6
|
//
|
|
7
|
-
// Second rule
|
|
7
|
+
// Second rule: raw form HTML (<input>/<select>/<textarea>/
|
|
8
8
|
// <label>) in app/feature web code, regardless of what the file imports —
|
|
9
9
|
// an import gate here previously let the designer's raw <input>s pass just
|
|
10
10
|
// by importing from @cosmicdrift/kumiko-renderer (types only) instead of
|
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
// closed against an empty baseline while no baseline file exists —
|
|
16
16
|
// otherwise the rule would stay silent in every app repo until someone
|
|
17
17
|
// manually runs --write-baseline there.
|
|
18
|
-
//
|
|
19
|
-
// Part of App-Mounting 2.0 (infra#208).
|
|
20
18
|
|
|
21
19
|
import { existsSync } from "node:fs";
|
|
22
20
|
import * as path from "node:path";
|
|
@@ -93,7 +91,7 @@ const RAW_FORM_HTML_REMEDIATION =
|
|
|
93
91
|
`or usePrimitives(). Real exception: // ${IGNORE_TAG} <reason>`;
|
|
94
92
|
|
|
95
93
|
// The rule is new: measured backlog is 0 in every locally checked repo
|
|
96
|
-
//
|
|
94
|
+
// . Without a baseline file, check fail-closed against an empty
|
|
97
95
|
// baseline instead of (as usual) warn-only until the first
|
|
98
96
|
// `--write-baseline` — otherwise the rule would stay silent in every app
|
|
99
97
|
// repo until it separately commits its own baseline file (every baseline
|
|
@@ -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$/ },
|
|
@@ -59,7 +59,7 @@ export function isScannableBinFile(rel: string): boolean {
|
|
|
59
59
|
// convention at all — "solon" is deliberately not on it) and intersected
|
|
60
60
|
// with the resolved roots, so a restricted scope actually narrows what this
|
|
61
61
|
// guard scans instead of always walking every repo regardless of scope
|
|
62
|
-
// (
|
|
62
|
+
// (follow-up — a money-horse-scoped push must not fail on a
|
|
63
63
|
// finding in publicstatus).
|
|
64
64
|
const CANDIDATE_ENV_TS_REPOS: ReadonlySet<string> = new Set([
|
|
65
65
|
"kumiko-studio",
|
|
@@ -81,7 +81,7 @@ export function findDirectProcessEnv(roots?: ReadonlyArray<RepoRoot>): ProcessEn
|
|
|
81
81
|
/**
|
|
82
82
|
* Like findDirectProcessEnv, additionally reports the number of files read
|
|
83
83
|
* and repos with bin/env.ts — without that an empty scan can't be told apart
|
|
84
|
-
* from a clean one
|
|
84
|
+
* from a clean one.
|
|
85
85
|
*/
|
|
86
86
|
export function scanDirectProcessEnv(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): {
|
|
87
87
|
readonly findings: ProcessEnvFinding[];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// No inline CSS in app code: `style={...}` props and `React.CSSProperties`
|
|
3
3
|
// style objects bypass theme tokens and dark mode. Use widgets/tokens.
|
|
4
4
|
//
|
|
5
|
-
// Part of App-Mounting 2.0
|
|
5
|
+
// Part of App-Mounting 2.0.
|
|
6
6
|
|
|
7
7
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
8
8
|
import {
|
|
@@ -96,7 +96,7 @@ type ScanScope = {
|
|
|
96
96
|
|
|
97
97
|
// Framework checkout enforces bundled-features + samples. App-repo checkout
|
|
98
98
|
// scans the app's web/ screens — primitives-discipline as a ratchet per app
|
|
99
|
-
// repo
|
|
99
|
+
// repo. Every other resolved repo (enterprise, platform, ...)
|
|
100
100
|
// scans alongside the others in one run — flatMap all of them instead of
|
|
101
101
|
// cascading, which would silently skip every root after the first, exactly
|
|
102
102
|
// the silent-skip this guard fixes.
|
|
@@ -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",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Layout utilities (flex/grid/gap/p-/m-/w-) and token classes
|
|
6
6
|
// (bg-primary, text-status-ok, …) stay allowed.
|
|
7
7
|
//
|
|
8
|
-
// Part of App-Mounting 2.0
|
|
8
|
+
// Part of App-Mounting 2.0. Enabled per app repo in the
|
|
9
9
|
// respective migration PR (ui-guards input in _app-test.yml).
|
|
10
10
|
|
|
11
11
|
import { type JsxAttribute, type SourceFile, SyntaxKind } from "ts-morph";
|