@cosmicdrift/kumiko-guards 0.356.0 → 0.358.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 +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard:
|
|
4
|
-
*
|
|
3
|
+
* Guard: app mount points must register the German locale feature
|
|
4
|
+
* when the repo depends on @cosmicdrift/kumiko-locale-de.
|
|
5
5
|
*
|
|
6
|
-
* guard-i18n-keys.ts
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
9
|
-
* (kumiko-studio#191, publicstatus#365, infra#533).
|
|
6
|
+
* guard-i18n-keys.ts only checks whether a used t() key is defined —
|
|
7
|
+
* and has no concept of a "mount point". A mount that never calls
|
|
8
|
+
* localeDeClient() (client) or localeDe() (server) slips through there
|
|
9
|
+
* completely (kumiko-studio#191, publicstatus#365, infra#533).
|
|
10
10
|
*
|
|
11
|
-
* Scope
|
|
12
|
-
* kumiko-locale-de
|
|
13
|
-
* offlot-app) —
|
|
14
|
-
* roots.ts'
|
|
15
|
-
*
|
|
11
|
+
* Scope gate: the package.json closest to the file must point at
|
|
12
|
+
* kumiko-locale-de (currently kumiko-studio, publicstatus, solon,
|
|
13
|
+
* offlot-app) — a node-resolution walk over ts-morph's FileSystemHost instead
|
|
14
|
+
* of guards/_lib/roots.ts' sibling-repo list, so the gate also fires in the
|
|
15
|
+
* in-memory test of the guard suite (no real sibling checkout).
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* bun guards/guard-i18n-locale-mount.ts
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type FileSystemHost,
|
|
24
24
|
type Identifier,
|
|
25
25
|
type Node,
|
|
26
|
+
type ObjectLiteralExpression,
|
|
26
27
|
type SourceFile,
|
|
27
28
|
SyntaxKind,
|
|
28
29
|
} from "ts-morph";
|
|
@@ -248,6 +249,39 @@ function isNothingLiteral(node: Node): boolean {
|
|
|
248
249
|
);
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
function lookupAcrossBranches(branches: readonly Node[], depth: number): ClientFeaturesLookup {
|
|
253
|
+
const found = branches
|
|
254
|
+
.map((branch) => lookupClientFeatures(branch, depth + 1))
|
|
255
|
+
.filter((lookup) => lookup.found);
|
|
256
|
+
if (found.length === 0) return CLIENT_FEATURES_ABSENT;
|
|
257
|
+
// Which branch runs is unknown statically: German must be registered in every branch that sets clientFeatures.
|
|
258
|
+
return {
|
|
259
|
+
found: true,
|
|
260
|
+
registersGerman: found.every((lookup) => lookup.found && lookup.registersGerman),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function lookupInObjectLiteral(obj: ObjectLiteralExpression, depth: number): ClientFeaturesLookup {
|
|
265
|
+
for (const prop of [...obj.getProperties()].reverse()) {
|
|
266
|
+
if (
|
|
267
|
+
(prop.isKind(SyntaxKind.PropertyAssignment) ||
|
|
268
|
+
prop.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
|
|
269
|
+
prop.getNameNode().getText() === "clientFeatures"
|
|
270
|
+
) {
|
|
271
|
+
return {
|
|
272
|
+
found: true,
|
|
273
|
+
registersGerman:
|
|
274
|
+
clientFeaturesElements(prop)?.some((el) => elementRegistersGerman(el)) ?? false,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (prop.isKind(SyntaxKind.SpreadAssignment)) {
|
|
278
|
+
const spread = lookupClientFeatures(prop.getExpression(), depth + 1);
|
|
279
|
+
if (spread.found) return spread;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return CLIENT_FEATURES_ABSENT;
|
|
283
|
+
}
|
|
284
|
+
|
|
251
285
|
// Finds the clientFeatures property that wins at runtime in the mount call's
|
|
252
286
|
// options — a literal right there, or an identifier pointing at a same-repo
|
|
253
287
|
// constant (offlot's `APP_OPTIONS` shape). Properties are walked last to
|
|
@@ -265,37 +299,10 @@ function lookupClientFeatures(value: Node | undefined, depth = 0): ClientFeature
|
|
|
265
299
|
: lookupClientFeatures(init, depth + 1);
|
|
266
300
|
}
|
|
267
301
|
const branches = conditionalBranches(unwrapped);
|
|
268
|
-
if (branches !== undefined)
|
|
269
|
-
const found = branches
|
|
270
|
-
.map((branch) => lookupClientFeatures(branch, depth + 1))
|
|
271
|
-
.filter((lookup) => lookup.found);
|
|
272
|
-
if (found.length === 0) return CLIENT_FEATURES_ABSENT;
|
|
273
|
-
// Which branch runs is unknown statically: German must be registered in every branch that sets clientFeatures.
|
|
274
|
-
return {
|
|
275
|
-
found: true,
|
|
276
|
-
registersGerman: found.every((lookup) => lookup.found && lookup.registersGerman),
|
|
277
|
-
};
|
|
278
|
-
}
|
|
302
|
+
if (branches !== undefined) return lookupAcrossBranches(branches, depth);
|
|
279
303
|
if (isNothingLiteral(unwrapped)) return CLIENT_FEATURES_ABSENT;
|
|
280
304
|
if (!unwrapped.isKind(SyntaxKind.ObjectLiteralExpression)) return CLIENT_FEATURES_UNRESOLVABLE;
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
(prop.isKind(SyntaxKind.PropertyAssignment) ||
|
|
284
|
-
prop.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
|
|
285
|
-
prop.getNameNode().getText() === "clientFeatures"
|
|
286
|
-
) {
|
|
287
|
-
return {
|
|
288
|
-
found: true,
|
|
289
|
-
registersGerman:
|
|
290
|
-
clientFeaturesElements(prop)?.some((el) => elementRegistersGerman(el)) ?? false,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
if (prop.isKind(SyntaxKind.SpreadAssignment)) {
|
|
294
|
-
const spread = lookupClientFeatures(prop.getExpression(), depth + 1);
|
|
295
|
-
if (spread.found) return spread;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return CLIENT_FEATURES_ABSENT;
|
|
305
|
+
return lookupInObjectLiteral(unwrapped, depth);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
function objectRegistersGermanClientFeatures(value: Node | undefined): boolean {
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// No hardcoded UI text in app web code: JSX text nodes and label-like
|
|
3
|
+
// string props must go through t("…") keys (guard-i18n-keys then checks
|
|
4
|
+
// that the keys are defined — this guard closes the gap before that:
|
|
5
|
+
// strings that never became keys, e.g. "Loading tenants…").
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Part of App-Mounting 2.0 (infra#208).
|
|
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";
|
|
11
11
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// Web/public code only — server code (handler error texts) goes through the
|
|
14
|
+
// error i18n pipeline and has its own guards.
|
|
15
15
|
//
|
|
16
16
|
// samples/ deliberately NOT scanned (tried in infra#478, rolled back): the
|
|
17
17
|
// showcase/gallery pages are dev docs about the framework API itself
|
|
@@ -38,8 +38,8 @@ const SCAN: ScanSpec = {
|
|
|
38
38
|
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
39
39
|
const IGNORE_TAG = "kumiko-lint-ignore i18n-ui-strings";
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// At least two consecutive letters = human-readable text (lets "—",
|
|
42
|
+
// numbers, punctuation and single characters through).
|
|
43
43
|
const HUMAN_TEXT = /\p{L}{2,}/u;
|
|
44
44
|
|
|
45
45
|
// i18n-Key-Shape (e.g. "tenant.nav.members", "money-horse:nav.scenarioCompare"):
|
|
@@ -147,6 +147,101 @@ function registrarCallChain(prop: Node): CallExpression[] | undefined {
|
|
|
147
147
|
return undefined;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
function checkJsxTextNodes(sf: SourceFile, violations: GuardViolation[]): void {
|
|
151
|
+
for (const textNode of sf.getDescendantsOfKind(SyntaxKind.JsxText)) {
|
|
152
|
+
const text = textNode.getText().trim();
|
|
153
|
+
if (!HUMAN_TEXT.test(text)) continue;
|
|
154
|
+
if (hasIgnoreTag(textNode, IGNORE_TAG)) continue;
|
|
155
|
+
violations.push({
|
|
156
|
+
file: sf.getFilePath(),
|
|
157
|
+
line: textNode.getStartLineNumber(),
|
|
158
|
+
message: `hardcoded JSX text: "${text.slice(0, 40)}${text.length > 40 ? "…" : ""}"`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkLabelAttributes(sf: SourceFile, violations: GuardViolation[]): void {
|
|
164
|
+
for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
165
|
+
if (!LABEL_PROPS.has(attr.getNameNode().getText())) continue;
|
|
166
|
+
const init = attr.getInitializer();
|
|
167
|
+
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
168
|
+
const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
|
|
169
|
+
if (!HUMAN_TEXT.test(value)) continue;
|
|
170
|
+
if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
|
|
171
|
+
violations.push({
|
|
172
|
+
file: sf.getFilePath(),
|
|
173
|
+
line: attr.getStartLineNumber(),
|
|
174
|
+
message: `hardcoded label prop ${attr.getNameNode().getText()}="${value.slice(0, 40)}"`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkTernaryLogicalExpressions(sf: SourceFile, violations: GuardViolation[]): void {
|
|
180
|
+
// Ternary/logical JSX expressions: `{saving ? "Saving…" : "Save"}` as a
|
|
181
|
+
// child, or `title={busy ? "…" : "Send"}` on a label prop — a string
|
|
182
|
+
// literal reachable only via getDescendantsOfKind(JsxText/JsxAttribute)
|
|
183
|
+
// above never fires here since the literal sits one level deeper,
|
|
184
|
+
// inside the {…} expression.
|
|
185
|
+
for (const jsxExpr of sf.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
|
|
186
|
+
const inner = jsxExpr.getExpression();
|
|
187
|
+
if (inner === undefined || !isTernaryOrLogical(inner)) continue;
|
|
188
|
+
const attr = jsxExpr.getParentIfKind(SyntaxKind.JsxAttribute);
|
|
189
|
+
if (attr !== undefined && !LABEL_PROPS.has(attr.getNameNode().getText())) continue;
|
|
190
|
+
if (hasIgnoreTag(jsxExpr, IGNORE_TAG)) continue;
|
|
191
|
+
for (const piece of collectTernaryLogicText(inner)) {
|
|
192
|
+
if (!HUMAN_TEXT.test(piece.text)) continue;
|
|
193
|
+
const shown = `${piece.text.slice(0, 40)}${piece.text.length > 40 ? "…" : ""}`;
|
|
194
|
+
violations.push({
|
|
195
|
+
file: sf.getFilePath(),
|
|
196
|
+
line: piece.node.getStartLineNumber(),
|
|
197
|
+
message:
|
|
198
|
+
attr !== undefined
|
|
199
|
+
? `hardcoded label prop ${attr.getNameNode().getText()} in ternary/logical expression: "${shown}"`
|
|
200
|
+
: `hardcoded JSX text in ternary/logical expression: "${shown}"`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function checkRegistrarLabelProperties(sf: SourceFile, violations: GuardViolation[]): void {
|
|
207
|
+
// r.nav({ label: "..." }) etc.: registrar param is conventionally
|
|
208
|
+
// named "r" across framework/bundled-features/app repos (verified,
|
|
209
|
+
// no exceptions found) — see the samples/ scan-boundary note above.
|
|
210
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
211
|
+
if (!LABEL_PROPS.has(prop.getName())) continue;
|
|
212
|
+
if (HEADER_SPEC_PROPS.has(prop.getName())) {
|
|
213
|
+
const enclosingObject = prop.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
214
|
+
const enclosingProp = enclosingObject?.getParentIfKind(SyntaxKind.PropertyAssignment);
|
|
215
|
+
if (enclosingProp?.getName() === "header") continue;
|
|
216
|
+
}
|
|
217
|
+
const init = prop.getInitializer();
|
|
218
|
+
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
219
|
+
const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
|
|
220
|
+
if (!HUMAN_TEXT.test(value) || I18N_KEY_SHAPE.test(value)) continue;
|
|
221
|
+
const callChain = registrarCallChain(prop);
|
|
222
|
+
if (callChain === undefined) continue;
|
|
223
|
+
// The agent-doc slot (kumiko-framework#2615) is LLM metadata for the
|
|
224
|
+
// AI tool catalog: English by design, never rendered as UI text. The
|
|
225
|
+
// slot is the options object handed to a call on the registrar
|
|
226
|
+
// argument chain, so forwarding it through a `defineEntity*Handler`
|
|
227
|
+
// helper is the same slot and stays exempt.
|
|
228
|
+
if (
|
|
229
|
+
prop.getName() === "description" &&
|
|
230
|
+
prop
|
|
231
|
+
.getParentIfKind(SyntaxKind.ObjectLiteralExpression)
|
|
232
|
+
?.getParentIfKind(SyntaxKind.CallExpression) !== undefined
|
|
233
|
+
)
|
|
234
|
+
continue;
|
|
235
|
+
if (callChain.some((c) => hasIgnoreTag(c, IGNORE_TAG)) || hasIgnoreTag(prop, IGNORE_TAG))
|
|
236
|
+
continue;
|
|
237
|
+
violations.push({
|
|
238
|
+
file: sf.getFilePath(),
|
|
239
|
+
line: prop.getStartLineNumber(),
|
|
240
|
+
message: `hardcoded label property ${prop.getName()}="${value.slice(0, 40)}" in r.* call — use an i18n key instead of plain text`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
150
245
|
export const guard: AstGuard = {
|
|
151
246
|
name: "i18n-UI-Strings Guard (App-Repos)",
|
|
152
247
|
scan: SCAN,
|
|
@@ -157,89 +252,10 @@ export const guard: AstGuard = {
|
|
|
157
252
|
const violations: GuardViolation[] = [];
|
|
158
253
|
for (const sf of files) {
|
|
159
254
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
violations.push({
|
|
165
|
-
file: sf.getFilePath(),
|
|
166
|
-
line: textNode.getStartLineNumber(),
|
|
167
|
-
message: `hardcoded JSX text: "${text.slice(0, 40)}${text.length > 40 ? "…" : ""}"`,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
171
|
-
if (!LABEL_PROPS.has(attr.getNameNode().getText())) continue;
|
|
172
|
-
const init = attr.getInitializer();
|
|
173
|
-
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
174
|
-
const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
|
|
175
|
-
if (!HUMAN_TEXT.test(value)) continue;
|
|
176
|
-
if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
|
|
177
|
-
violations.push({
|
|
178
|
-
file: sf.getFilePath(),
|
|
179
|
-
line: attr.getStartLineNumber(),
|
|
180
|
-
message: `hardcoded label prop ${attr.getNameNode().getText()}="${value.slice(0, 40)}"`,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
// Ternary/logical JSX expressions: `{saving ? "Saving…" : "Save"}` as a
|
|
184
|
-
// child, or `title={busy ? "…" : "Send"}` on a label prop — a string
|
|
185
|
-
// literal reachable only via getDescendantsOfKind(JsxText/JsxAttribute)
|
|
186
|
-
// above never fires here since the literal sits one level deeper,
|
|
187
|
-
// inside the {…} expression.
|
|
188
|
-
for (const jsxExpr of sf.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
|
|
189
|
-
const inner = jsxExpr.getExpression();
|
|
190
|
-
if (inner === undefined || !isTernaryOrLogical(inner)) continue;
|
|
191
|
-
const attr = jsxExpr.getParentIfKind(SyntaxKind.JsxAttribute);
|
|
192
|
-
if (attr !== undefined && !LABEL_PROPS.has(attr.getNameNode().getText())) continue;
|
|
193
|
-
if (hasIgnoreTag(jsxExpr, IGNORE_TAG)) continue;
|
|
194
|
-
for (const piece of collectTernaryLogicText(inner)) {
|
|
195
|
-
if (!HUMAN_TEXT.test(piece.text)) continue;
|
|
196
|
-
const shown = `${piece.text.slice(0, 40)}${piece.text.length > 40 ? "…" : ""}`;
|
|
197
|
-
violations.push({
|
|
198
|
-
file: sf.getFilePath(),
|
|
199
|
-
line: piece.node.getStartLineNumber(),
|
|
200
|
-
message:
|
|
201
|
-
attr !== undefined
|
|
202
|
-
? `hardcoded label prop ${attr.getNameNode().getText()} in ternary/logical expression: "${shown}"`
|
|
203
|
-
: `hardcoded JSX text in ternary/logical expression: "${shown}"`,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// r.nav({ label: "..." }) etc.: registrar param is conventionally
|
|
208
|
-
// named "r" across framework/bundled-features/app repos (verified,
|
|
209
|
-
// no exceptions found) — see the samples/ scan-boundary note above.
|
|
210
|
-
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
211
|
-
if (!LABEL_PROPS.has(prop.getName())) continue;
|
|
212
|
-
if (HEADER_SPEC_PROPS.has(prop.getName())) {
|
|
213
|
-
const enclosingObject = prop.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
214
|
-
const enclosingProp = enclosingObject?.getParentIfKind(SyntaxKind.PropertyAssignment);
|
|
215
|
-
if (enclosingProp?.getName() === "header") continue;
|
|
216
|
-
}
|
|
217
|
-
const init = prop.getInitializer();
|
|
218
|
-
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
219
|
-
const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
|
|
220
|
-
if (!HUMAN_TEXT.test(value) || I18N_KEY_SHAPE.test(value)) continue;
|
|
221
|
-
const callChain = registrarCallChain(prop);
|
|
222
|
-
if (callChain === undefined) continue;
|
|
223
|
-
// The agent-doc slot (kumiko-framework#2615) is LLM metadata for the
|
|
224
|
-
// AI tool catalog: English by design, never rendered as UI text. The
|
|
225
|
-
// slot is the options object handed to a call on the registrar
|
|
226
|
-
// argument chain, so forwarding it through a `defineEntity*Handler`
|
|
227
|
-
// helper is the same slot and stays exempt.
|
|
228
|
-
if (
|
|
229
|
-
prop.getName() === "description" &&
|
|
230
|
-
prop
|
|
231
|
-
.getParentIfKind(SyntaxKind.ObjectLiteralExpression)
|
|
232
|
-
?.getParentIfKind(SyntaxKind.CallExpression) !== undefined
|
|
233
|
-
)
|
|
234
|
-
continue;
|
|
235
|
-
if (callChain.some((c) => hasIgnoreTag(c, IGNORE_TAG)) || hasIgnoreTag(prop, IGNORE_TAG))
|
|
236
|
-
continue;
|
|
237
|
-
violations.push({
|
|
238
|
-
file: sf.getFilePath(),
|
|
239
|
-
line: prop.getStartLineNumber(),
|
|
240
|
-
message: `hardcoded label property ${prop.getName()}="${value.slice(0, 40)}" in r.* call — use an i18n key instead of plain text`,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
255
|
+
checkJsxTextNodes(sf, violations);
|
|
256
|
+
checkLabelAttributes(sf, violations);
|
|
257
|
+
checkTernaryLogicalExpressions(sf, violations);
|
|
258
|
+
checkRegistrarLabelProperties(sf, violations);
|
|
243
259
|
}
|
|
244
260
|
return { violations };
|
|
245
261
|
},
|
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// lib/
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// vorkommen.
|
|
2
|
+
// lib/ is the home of extracted logic (computation, parsing, mapping) —
|
|
3
|
+
// the no-logic-in-views guard pushes it there, this guard ensures it is also
|
|
4
|
+
// TESTED at the new location. For every lib file with at least one exported
|
|
5
|
+
// function, a test must exist that imports from the module, and every
|
|
6
|
+
// exported function must appear there by name.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// "
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
8
|
+
// Why static (ts-morph) instead of a coverage threshold: coverage lies twice —
|
|
9
|
+
// it does not see never-imported modules at all, and "executed" does not mean
|
|
10
|
+
// "meaningfully tested" (an integration test that touches the function
|
|
11
|
+
// transitively over HTTP turns it green without a single assertion on its
|
|
12
|
+
// behavior). This guard is the structural counterpart: it couples test↔module
|
|
13
|
+
// through the real import and counts references by name, not lines. The
|
|
14
|
+
// coverage threshold in bunfig.toml stays in place alongside — both together,
|
|
15
|
+
// never the threshold alone.
|
|
17
16
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// Excluded: files without an exported function (pure types/constants like
|
|
18
|
+
// a color map — a test for those would be a fake test). IO loaders already
|
|
19
|
+
// covered by an integration test over HTTP carry the ignore tag with a
|
|
20
|
+
// truthful justification.
|
|
22
21
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
22
|
+
// Deliberately out of scope: re-exports (`export { foo } from "./bar"`) do not
|
|
23
|
+
// count as their own callable — the source file "./bar" carries its own
|
|
24
|
+
// coverage duty. If lib/ ever contains re-exported functions without their own
|
|
25
|
+
// source file in scope, that is a silent blind spot; so far (as of this fix
|
|
26
|
+
// round) the pattern occurs in no app repo.
|
|
28
27
|
|
|
29
28
|
import { dirname, resolve } from "node:path";
|
|
30
29
|
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -53,9 +52,9 @@ function isTestSupportFile(path: string): boolean {
|
|
|
53
52
|
return /\/__tests__\//.test(path);
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
//
|
|
57
|
-
// `= function () {}`.
|
|
58
|
-
//
|
|
55
|
+
// Exported callables: `export function f` and `export const f = () => …` /
|
|
56
|
+
// `= function () {}`. Pure values (`export const X = 5`), types and interfaces
|
|
57
|
+
// do not count — they carry no logic a behavior test could check.
|
|
59
58
|
function exportedCallables(sf: SourceFile): Export[] {
|
|
60
59
|
const out: Export[] = [];
|
|
61
60
|
for (const fd of sf.getFunctions()) {
|
|
@@ -76,17 +75,18 @@ function exportedCallables(sf: SourceFile): Export[] {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// NodeNext-Importe schreiben ".js"/".jsx", obwohl die Quelle ".ts"/".tsx" ist
|
|
79
|
-
// (
|
|
80
|
-
//
|
|
78
|
+
// (and likewise .mjs/.cjs for .mts/.cts) — the extension must therefore be
|
|
79
|
+
// stripped for all four pair variants when comparing module paths, otherwise
|
|
80
|
+
// the link breaks.
|
|
81
81
|
function stripExt(path: string): string {
|
|
82
82
|
return path.replace(/\.(?:[cm]?tsx?|[cm]?jsx?)$/, "");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
85
|
+
// A test is linked to a lib file when it imports from exactly that file by
|
|
86
|
+
// relative path — not by path convention. This allows tests one level above
|
|
87
|
+
// lib/ (features/<x>/__tests__/foo.test.ts → "../lib/foo") and prevents
|
|
88
|
+
// generic names (fieldText, targetForRow) in unrelated tests from wrongly
|
|
89
|
+
// counting as a reference.
|
|
90
90
|
function testImportsLib(testSf: SourceFile, libPathNoExt: string): boolean {
|
|
91
91
|
const testDir = dirname(testSf.getFilePath());
|
|
92
92
|
for (const imp of testSf.getImportDeclarations()) {
|
|
@@ -97,10 +97,10 @@ function testImportsLib(testSf: SourceFile, libPathNoExt: string): boolean {
|
|
|
97
97
|
return false;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
// (`import { addFees, subFees } from "../calc"`,
|
|
102
|
-
//
|
|
103
|
-
//
|
|
100
|
+
// Exclude import declarations: a named import without any usage
|
|
101
|
+
// (`import { addFees, subFees } from "../calc"`, only addFees called)
|
|
102
|
+
// used to satisfy the guard for subFees through the import text alone —
|
|
103
|
+
// the per-function check was nearly tautological for named imports.
|
|
104
104
|
function bodyTextWithoutImports(testSf: SourceFile): string {
|
|
105
105
|
const importRanges = testSf
|
|
106
106
|
.getDescendantsOfKind(SyntaxKind.ImportDeclaration)
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard:
|
|
4
|
-
*
|
|
3
|
+
* Guard: blocks calls of `loadAllEventsByType()` outside tests,
|
|
4
|
+
* ops scripts and the definition itself.
|
|
5
5
|
*
|
|
6
|
-
* `loadAllEventsByType`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `loadAllEventsByType` buffers ALL events of an aggregate_type in
|
|
7
|
+
* memory (a `SELECT … ORDER BY` without a limit). Beyond ~100k events per
|
|
8
|
+
* type that is an OOM cliff that only shows under prod load — exactly the
|
|
9
|
+
* kind of silent-and-late failure a guard catches before it ships.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* The memory-bounded replacement is `streamAllEventsByType` (yields in
|
|
12
|
+
* batches, never more than batchSize rows resident). Production projection
|
|
13
|
+
* rebuild already goes through this streaming path. `loadAllEventsByType` stays legitimate
|
|
14
|
+
* for tests (small, controlled stores) and ops scripts on known
|
|
15
|
+
* small aggregate_types — hence the allowlist instead of a removal.
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* bun packages/guards/src/guard-loadall-events.ts
|
|
@@ -36,14 +36,14 @@ const SCAN: ScanSpec = {
|
|
|
36
36
|
kinds: ["framework", "library"],
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
// Test
|
|
40
|
-
//
|
|
39
|
+
// Test files may use the API freely — they are the primary verifiers and
|
|
40
|
+
// run against small, controlled stores.
|
|
41
41
|
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// ROOT
|
|
46
|
-
//
|
|
43
|
+
// Allowed callers: the definition itself + ops/migration scripts (run
|
|
44
|
+
// on known small aggregate_types, not in the prod hot path).
|
|
45
|
+
// ROOT is anchored to process.cwd() (repo root), the `^` anchor only matches
|
|
46
|
+
// real top-level scripts/, not packages/.../scripts/*.
|
|
47
47
|
const ALLOWLIST: readonly RegExp[] = [
|
|
48
48
|
/^packages\/framework\/src\/event-store\/event-store\.ts$/,
|
|
49
49
|
/^scripts\//,
|
|
@@ -106,6 +106,7 @@ function addRegistryMembers(
|
|
|
106
106
|
entries: Map<string, string>,
|
|
107
107
|
visited: Set<Node>,
|
|
108
108
|
): void {
|
|
109
|
+
// skip: a registry spread into itself would otherwise recurse forever
|
|
109
110
|
if (visited.has(literal)) return;
|
|
110
111
|
visited.add(literal);
|
|
111
112
|
for (const member of literal.getProperties()) {
|
|
@@ -140,11 +141,10 @@ function addRegistryFromExpression(
|
|
|
140
141
|
): void {
|
|
141
142
|
if (Node.isObjectLiteralExpression(expression)) {
|
|
142
143
|
addRegistryMembers(sf, expression, entries, visited);
|
|
143
|
-
|
|
144
|
+
} else if (Node.isIdentifier(expression)) {
|
|
145
|
+
const init = sf.getVariableDeclaration(expression.getText())?.getInitializer();
|
|
146
|
+
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
144
147
|
}
|
|
145
|
-
if (!Node.isIdentifier(expression)) return;
|
|
146
|
-
const init = sf.getVariableDeclaration(expression.getText())?.getInitializer();
|
|
147
|
-
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/** Maps each registered component's identifier to the registry key usage sites reference via `__component: SOME_CONST`. */
|
|
@@ -61,7 +61,7 @@ function isTypeOnlyImport(decl: ImportDeclaration): boolean {
|
|
|
61
61
|
|
|
62
62
|
type PolyfillImport = { readonly line: number; readonly specifier: string };
|
|
63
63
|
|
|
64
|
-
function
|
|
64
|
+
function findPolyfillImportDeclarations(sf: SourceFile): PolyfillImport[] {
|
|
65
65
|
const found: PolyfillImport[] = [];
|
|
66
66
|
for (const decl of sf.getImportDeclarations()) {
|
|
67
67
|
const specifier = decl.getModuleSpecifierValue();
|
|
@@ -69,12 +69,22 @@ function findPolyfillValueImports(sf: SourceFile): PolyfillImport[] {
|
|
|
69
69
|
found.push({ line: decl.getStartLineNumber(), specifier });
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findPolyfillExportDeclarations(sf: SourceFile): PolyfillImport[] {
|
|
76
|
+
const found: PolyfillImport[] = [];
|
|
72
77
|
for (const decl of sf.getExportDeclarations()) {
|
|
73
78
|
const specifier = decl.getModuleSpecifierValue();
|
|
74
79
|
if (specifier !== undefined && isPolyfillSpecifier(specifier) && !decl.isTypeOnly()) {
|
|
75
80
|
found.push({ line: decl.getStartLineNumber(), specifier });
|
|
76
81
|
}
|
|
77
82
|
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findPolyfillRequireOrDynamicImports(sf: SourceFile): PolyfillImport[] {
|
|
87
|
+
const found: PolyfillImport[] = [];
|
|
78
88
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
79
89
|
const callee = call.getExpression();
|
|
80
90
|
const isRequireCall = Node.isIdentifier(callee) && callee.getText() === "require";
|
|
@@ -88,6 +98,14 @@ function findPolyfillValueImports(sf: SourceFile): PolyfillImport[] {
|
|
|
88
98
|
return found;
|
|
89
99
|
}
|
|
90
100
|
|
|
101
|
+
function findPolyfillValueImports(sf: SourceFile): PolyfillImport[] {
|
|
102
|
+
return [
|
|
103
|
+
...findPolyfillImportDeclarations(sf),
|
|
104
|
+
...findPolyfillExportDeclarations(sf),
|
|
105
|
+
...findPolyfillRequireOrDynamicImports(sf),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
export const guard: AstGuard = {
|
|
92
110
|
name: "No-Temporal-Polyfill-Import Guard",
|
|
93
111
|
scan: SCAN,
|