@cosmicdrift/kumiko-guards 0.1.1 → 0.281.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 +5 -2
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +32 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +193 -0
- package/src/guard-escape-hatch-declared.ts +155 -35
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
* R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
|
|
7
7
|
* `r.systemScope()` feature.
|
|
8
8
|
* R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
|
|
9
|
-
* systemScope
|
|
10
|
-
* `escapeHatch
|
|
9
|
+
* systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, a
|
|
10
|
+
* handler/hook that lexically declares `escapeHatch`, or a standalone
|
|
11
|
+
* function whose direct body declares `declareEscapeHatch({ reason: "..." })`.
|
|
11
12
|
* R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
|
|
12
13
|
* with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
|
|
13
|
-
* call, a `*Job` function,
|
|
14
|
-
* `escapeHatch
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* call, a `*Job` function, a handler/hook that lexically declares
|
|
15
|
+
* `escapeHatch`, or a standalone function whose direct body declares
|
|
16
|
+
* `declareEscapeHatch({ reason: "..." })`.
|
|
17
|
+
* R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, a declared
|
|
18
|
+
* `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, or a
|
|
19
|
+
* `declareEscapeHatch({ reason })` call, given a placeholder reason
|
|
20
|
+
* literal — hard-fails everywhere, not baselined.
|
|
18
21
|
* R5 unsafe-all-tenants-outside-declared-scope: an `unsafeAllTenants: true`
|
|
19
22
|
* or `unsafeAllTenants: { reason: "..." }` option, passed directly as a
|
|
20
23
|
* call argument, outside systemScope, `.job.ts`, an `r.job(...)` call,
|
|
@@ -22,28 +25,53 @@
|
|
|
22
25
|
*
|
|
23
26
|
* escapeHatch (R2/R3) is recognized only as a direct, literal `escapeHatch`
|
|
24
27
|
* property (object literal, or ternary of two object literals) either in the
|
|
25
|
-
* same object literal as
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* same object literal as any inline function property (ArrowFunction or
|
|
29
|
+
* FunctionExpression, under any key name — e.g. `handler`, `export`,
|
|
30
|
+
* `delete`), or in an options object passed to
|
|
31
|
+
* `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
|
|
32
|
+
* alongside the handler function argument. A standalone function (arrow
|
|
33
|
+
* function, function expression, method declaration, or function
|
|
34
|
+
* declaration) is additionally recognized when a statement in its own direct
|
|
35
|
+
* body — not a nested function's, not inside an `if` — calls the bare
|
|
36
|
+
* identifier `declareEscapeHatch` with exactly one object-literal argument
|
|
37
|
+
* carrying a literal, non-placeholder `reason` (from
|
|
38
|
+
* `@cosmicdrift/kumiko-framework/engine`'s `declareEscapeHatch`: a helper
|
|
39
|
+
* that escalates on a `HandlerContext` handed to it by its caller, rather
|
|
40
|
+
* than a `HandlerContext` from its own registration). The declaration does
|
|
41
|
+
* not propagate upward: it covers escalations inside that function's own
|
|
42
|
+
* body, not the function it is nested inside. This detection is purely
|
|
43
|
+
* lexical — the guard matches on the name `declareEscapeHatch`, not on where
|
|
44
|
+
* it was imported from, so a same-named local function clears just as well;
|
|
45
|
+
* consistent with `escapeHatch:` itself, which is likewise never checked for
|
|
46
|
+
* origin. Referenced-by-variable functions, spread options, computed/string
|
|
47
|
+
* keys, and non-literal escapeHatch/reason values are conservatively not
|
|
48
|
+
* recognized (miss, don't falsely clear).
|
|
30
49
|
*
|
|
31
50
|
* Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
|
|
32
|
-
* job (access-declarations.ts), not this guard's
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* falsely flag).
|
|
51
|
+
* job (access-declarations.ts), not this guard's — except a `declareEscapeHatch`
|
|
52
|
+
* reason, which has no boot validator behind it: an empty or placeholder
|
|
53
|
+
* reason there is never recognized as a valid declaration (see R4). Known
|
|
54
|
+
* false-negatives: multi-hop aliasing, `ctx["db"]` through an intermediate
|
|
55
|
+
* variable, and a TenantDb/system-identity handed to another function across
|
|
56
|
+
* file boundaries with no `declareEscapeHatch` call at the escalation site
|
|
57
|
+
* (declarable now, so no longer a blanket false-negative) — all conservative
|
|
58
|
+
* (miss, don't falsely flag). R5 additionally misses `unsafeAllTenants`
|
|
59
|
+
* given via an identifier, a ternary, `false`, or `undefined`, and an
|
|
60
|
+
* options object passed by variable reference or spread rather than as a
|
|
61
|
+
* literal call argument — all conservative (miss, don't falsely flag).
|
|
40
62
|
*
|
|
41
63
|
* Usage:
|
|
42
64
|
* bun guards/guard-escape-hatch-declared.ts
|
|
43
65
|
* Baseline: bun guards/run-guards.ts --write-security-baseline
|
|
44
66
|
*/
|
|
45
67
|
import * as path from "node:path";
|
|
46
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
type Node,
|
|
70
|
+
type ObjectLiteralExpression,
|
|
71
|
+
type PropertyAssignment,
|
|
72
|
+
type SourceFile,
|
|
73
|
+
SyntaxKind,
|
|
74
|
+
} from "ts-morph";
|
|
47
75
|
import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
|
|
48
76
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
49
77
|
|
|
@@ -174,7 +202,7 @@ function findUnsafeRawFindings(
|
|
|
174
202
|
const expr = call.getExpression();
|
|
175
203
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
176
204
|
if (expr.getName() !== "unsafeRaw") continue;
|
|
177
|
-
if (
|
|
205
|
+
if (isAllowedEscapeHatchCall(call, sf, systemDirs) || isExplicitUnsafeRawGrant(call)) continue;
|
|
178
206
|
out.push({
|
|
179
207
|
file: path.relative(root, sf.getFilePath()),
|
|
180
208
|
line: call.getStartLineNumber(),
|
|
@@ -186,6 +214,19 @@ function findUnsafeRawFindings(
|
|
|
186
214
|
return out;
|
|
187
215
|
}
|
|
188
216
|
|
|
217
|
+
function isExplicitUnsafeRawGrant(call: Node): boolean {
|
|
218
|
+
if (!call.isKind(SyntaxKind.CallExpression)) return false;
|
|
219
|
+
const expr = call.getExpression();
|
|
220
|
+
if (!expr.isKind(SyntaxKind.PropertyAccessExpression) || expr.getName() !== "unsafeRaw") {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const receiver = expr.getExpression();
|
|
224
|
+
return (
|
|
225
|
+
receiver.isKind(SyntaxKind.CallExpression) &&
|
|
226
|
+
receiver.getExpression().getText() === "withUnsafeRawGrant"
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
189
230
|
function isSystemIdentityExpression(node: Node): boolean {
|
|
190
231
|
if (node.isKind(SyntaxKind.CallExpression)) {
|
|
191
232
|
const calleeText = node.getExpression().getText();
|
|
@@ -270,6 +311,7 @@ const ESCAPE_HATCH_CALL_METHODS = new Set([
|
|
|
270
311
|
"writeHandler",
|
|
271
312
|
"queryHandler",
|
|
272
313
|
"streamHandler",
|
|
314
|
+
"useExtension",
|
|
273
315
|
]);
|
|
274
316
|
|
|
275
317
|
function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
@@ -277,13 +319,7 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
277
319
|
if (!parent) return false;
|
|
278
320
|
|
|
279
321
|
if (fn.isKind(SyntaxKind.MethodDeclaration)) {
|
|
280
|
-
|
|
281
|
-
return (
|
|
282
|
-
nameNode.isKind(SyntaxKind.Identifier) &&
|
|
283
|
-
nameNode.getText() === "handler" &&
|
|
284
|
-
parent.isKind(SyntaxKind.ObjectLiteralExpression) &&
|
|
285
|
-
objectDeclaresEscapeHatch(parent)
|
|
286
|
-
);
|
|
322
|
+
return parent.isKind(SyntaxKind.ObjectLiteralExpression) && objectDeclaresEscapeHatch(parent);
|
|
287
323
|
}
|
|
288
324
|
|
|
289
325
|
if (!fn.isKind(SyntaxKind.ArrowFunction) && !fn.isKind(SyntaxKind.FunctionExpression)) {
|
|
@@ -291,11 +327,8 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
if (parent.isKind(SyntaxKind.PropertyAssignment)) {
|
|
294
|
-
const nameNode = parent.getNameNode();
|
|
295
330
|
const obj = parent.getParent();
|
|
296
331
|
return (
|
|
297
|
-
nameNode.isKind(SyntaxKind.Identifier) &&
|
|
298
|
-
nameNode.getText() === "handler" &&
|
|
299
332
|
parent.getInitializer() === fn &&
|
|
300
333
|
obj.isKind(SyntaxKind.ObjectLiteralExpression) &&
|
|
301
334
|
objectDeclaresEscapeHatch(obj)
|
|
@@ -323,15 +356,72 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
323
356
|
return false;
|
|
324
357
|
}
|
|
325
358
|
|
|
359
|
+
// The bare-identifier `reason` PropertyAssignment on an object literal —
|
|
360
|
+
// shared between the declareEscapeHatch statement check below and its R4
|
|
361
|
+
// generic-reason collector, so both agree on what counts as the reason.
|
|
362
|
+
function findReasonPropertyAssignment(
|
|
363
|
+
obj: ObjectLiteralExpression,
|
|
364
|
+
): PropertyAssignment | undefined {
|
|
365
|
+
return obj
|
|
366
|
+
.getProperties()
|
|
367
|
+
.find(
|
|
368
|
+
(prop): prop is PropertyAssignment =>
|
|
369
|
+
prop.isKind(SyntaxKind.PropertyAssignment) &&
|
|
370
|
+
prop.getNameNode().isKind(SyntaxKind.Identifier) &&
|
|
371
|
+
prop.getNameNode().getText() === "reason",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
|
|
376
|
+
// standalone function. No boot validator backs this form (unlike the
|
|
377
|
+
// escapeHatch: {...} property, which access-declarations.ts checks at boot),
|
|
378
|
+
// so an empty/placeholder reason is rejected here rather than left to it.
|
|
379
|
+
function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
|
|
380
|
+
if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return false;
|
|
381
|
+
const expr = stmt.getExpression();
|
|
382
|
+
if (!expr.isKind(SyntaxKind.CallExpression)) return false;
|
|
383
|
+
const callee = expr.getExpression();
|
|
384
|
+
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
const args = expr.getArguments();
|
|
388
|
+
const arg = args[0];
|
|
389
|
+
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
|
|
390
|
+
const reasonProp = findReasonPropertyAssignment(arg);
|
|
391
|
+
if (!reasonProp) return false;
|
|
392
|
+
const reasonText = literalReasonText(reasonProp.getInitializer());
|
|
393
|
+
return reasonText !== undefined && !isGenericReason(reasonText);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Only the function's own direct body — not a nested function's, not an
|
|
397
|
+
// `if`'s — so a declareEscapeHatch call does not cover the function it is
|
|
398
|
+
// itself nested inside (miss, don't falsely clear).
|
|
399
|
+
function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
|
|
400
|
+
let body: Node | undefined;
|
|
401
|
+
if (
|
|
402
|
+
fn.isKind(SyntaxKind.ArrowFunction) ||
|
|
403
|
+
fn.isKind(SyntaxKind.FunctionExpression) ||
|
|
404
|
+
fn.isKind(SyntaxKind.MethodDeclaration) ||
|
|
405
|
+
fn.isKind(SyntaxKind.FunctionDeclaration)
|
|
406
|
+
) {
|
|
407
|
+
body = fn.getBody();
|
|
408
|
+
}
|
|
409
|
+
if (!body?.isKind(SyntaxKind.Block)) return false;
|
|
410
|
+
return body.getStatements().some((stmt) => isValidDeclareEscapeHatchCall(stmt));
|
|
411
|
+
}
|
|
412
|
+
|
|
326
413
|
function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
|
|
327
414
|
let ancestor: Node | undefined = node.getParent();
|
|
328
415
|
while (ancestor) {
|
|
329
416
|
if (
|
|
330
417
|
ancestor.isKind(SyntaxKind.ArrowFunction) ||
|
|
331
418
|
ancestor.isKind(SyntaxKind.FunctionExpression) ||
|
|
332
|
-
ancestor.isKind(SyntaxKind.MethodDeclaration)
|
|
419
|
+
ancestor.isKind(SyntaxKind.MethodDeclaration) ||
|
|
420
|
+
ancestor.isKind(SyntaxKind.FunctionDeclaration)
|
|
333
421
|
) {
|
|
334
|
-
if (isEscapeHatchDeclaredFunction(ancestor))
|
|
422
|
+
if (isEscapeHatchDeclaredFunction(ancestor) || hasDeclaredEscapeHatchStatement(ancestor)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
335
425
|
}
|
|
336
426
|
ancestor = ancestor.getParent();
|
|
337
427
|
}
|
|
@@ -446,6 +536,35 @@ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReas
|
|
|
446
536
|
return out;
|
|
447
537
|
}
|
|
448
538
|
|
|
539
|
+
// The declareEscapeHatch({ reason }) form falls through both existing R4
|
|
540
|
+
// collectors: its callee is a bare identifier, not a PropertyAccessExpression
|
|
541
|
+
// (unlike acknowledgeCrossTenant/unsafeRaw), and its reason sits directly in
|
|
542
|
+
// the call argument, not under an escapeHatch:/unsafeAllTenants: property.
|
|
543
|
+
function findGenericReasonDeclareEscapeHatchCalls(
|
|
544
|
+
sf: SourceFile,
|
|
545
|
+
root: string,
|
|
546
|
+
): GenericReasonFinding[] {
|
|
547
|
+
const out: GenericReasonFinding[] = [];
|
|
548
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
549
|
+
const callee = call.getExpression();
|
|
550
|
+
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const arg = call.getArguments()[0];
|
|
554
|
+
if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
555
|
+
const reasonProp = findReasonPropertyAssignment(arg);
|
|
556
|
+
if (!reasonProp) continue;
|
|
557
|
+
const reasonText = literalReasonText(reasonProp.getInitializer());
|
|
558
|
+
if (reasonText === undefined || !isGenericReason(reasonText)) continue;
|
|
559
|
+
out.push({
|
|
560
|
+
file: path.relative(root, sf.getFilePath()),
|
|
561
|
+
line: call.getStartLineNumber(),
|
|
562
|
+
message: `declareEscapeHatch({ reason: "${reasonText}" }) uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
449
568
|
const REASON_OBJECT_PROPERTY_NAMES = ["escapeHatch", "unsafeAllTenants"] as const;
|
|
450
569
|
|
|
451
570
|
function findGenericReasonObjectProperty(
|
|
@@ -481,6 +600,7 @@ export function findGenericReasonCalls(
|
|
|
481
600
|
const out: GenericReasonFinding[] = [];
|
|
482
601
|
for (const sf of scannableFiles(files)) {
|
|
483
602
|
out.push(...findGenericReasonMethodCalls(sf, root));
|
|
603
|
+
out.push(...findGenericReasonDeclareEscapeHatchCalls(sf, root));
|
|
484
604
|
for (const propertyName of REASON_OBJECT_PROPERTY_NAMES) {
|
|
485
605
|
out.push(...findGenericReasonObjectProperty(sf, root, propertyName));
|
|
486
606
|
}
|
|
@@ -493,7 +613,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
|
|
|
493
613
|
name: "Escape-Hatch-Declared Guard",
|
|
494
614
|
scan: SCAN,
|
|
495
615
|
security: true,
|
|
496
|
-
hint: '
|
|
616
|
+
hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `bun guards/run-guards.ts --write-security-baseline`',
|
|
497
617
|
run(files) {
|
|
498
618
|
const violations: GuardViolation[] = [
|
|
499
619
|
...findGenericReasonCalls(files, opts.root).map((f) => ({
|
package/src/guard-fake-tests.ts
CHANGED
|
@@ -117,7 +117,7 @@ function scanFile(sf: SourceFile): Violation[] {
|
|
|
117
117
|
export const guard: AstGuard = {
|
|
118
118
|
name: "Fake-Test Guard",
|
|
119
119
|
scan: SCAN,
|
|
120
|
-
hint: "Test
|
|
120
|
+
hint: "Test without expect() or with a tautology — check real behavior, not existence.",
|
|
121
121
|
run(files) {
|
|
122
122
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
123
123
|
for (const sf of files) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: every feature in packages/bundled-features/src/<name>/feature.ts
|
|
4
|
+
* must be imported by at least one *.integration.ts. Otherwise it never ran
|
|
5
|
+
* through the full stack — exactly the "feature built, never wired up" case
|
|
6
|
+
* from CLAUDE.md.
|
|
7
|
+
*
|
|
8
|
+
* Coverage rule: a feature counts as covered when an integration test
|
|
9
|
+
* imports anything — relative or via the `@cosmicdrift/kumiko-bundled-features`
|
|
10
|
+
* package — that resolves into its `packages/bundled-features/src/<name>/`
|
|
11
|
+
* directory. That's deliberately broader than "imports feature.ts itself":
|
|
12
|
+
* a test that imports a sibling module of the feature (e.g. its resolver,
|
|
13
|
+
* its defaults helper) still proves the directory ran, and a barrel import
|
|
14
|
+
* of the whole package still proves the feature was composed. Since the
|
|
15
|
+
* `<name>-feature.ts` -> `feature.ts` rename (df3f6b5b) the file basename is
|
|
16
|
+
* identical for all 56 features — the directory name is the only usable
|
|
17
|
+
* identifier.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* bun guards/guard-feature-integration-tests.ts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
25
|
+
import {
|
|
26
|
+
type GuardViolation,
|
|
27
|
+
type RepoCheck,
|
|
28
|
+
reportResults,
|
|
29
|
+
runRepoChecks,
|
|
30
|
+
} from "./_lib/guard-kit";
|
|
31
|
+
import { frameworkPackageTsConfigPath, type RepoRoot } from "./_lib/roots";
|
|
32
|
+
import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
33
|
+
|
|
34
|
+
const ROOT = process.cwd();
|
|
35
|
+
|
|
36
|
+
const FEATURES_SCAN: ScanSpec = {
|
|
37
|
+
scope: "source",
|
|
38
|
+
extensions: ["ts"],
|
|
39
|
+
kinds: ["framework"],
|
|
40
|
+
frameworkWithin: ["packages/bundled-features/src/**/feature.ts"],
|
|
41
|
+
};
|
|
42
|
+
// Both suffixes: `.integration.ts` (legacy) and `.integration.test.ts`
|
|
43
|
+
// (canonical after the bun-test cutover). dev-server covers features
|
|
44
|
+
// indirectly (walkthrough tests that compose real bundled features) — must
|
|
45
|
+
// be scanned too, otherwise those feature references stay invisible.
|
|
46
|
+
const INTEGRATION_SCAN: ScanSpec = {
|
|
47
|
+
scope: "source",
|
|
48
|
+
extensions: ["ts"],
|
|
49
|
+
kinds: ["framework"],
|
|
50
|
+
frameworkWithin: [
|
|
51
|
+
"packages/bundled-features/src/**/*.integration.ts",
|
|
52
|
+
"packages/bundled-features/src/**/*.integration.test.ts",
|
|
53
|
+
"packages/framework/src/**/*.integration.ts",
|
|
54
|
+
"packages/framework/src/**/*.integration.test.ts",
|
|
55
|
+
"packages/dev-server/src/**/*.integration.ts",
|
|
56
|
+
"packages/dev-server/src/**/*.integration.test.ts",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const BUNDLED_FEATURES_PACKAGE_SPEC = /^@cosmicdrift\/kumiko-bundled-features\/([^/]+)/;
|
|
61
|
+
const BUNDLED_FEATURES_SRC_DIR = /bundled-features\/src\/([^/]+)\//;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves an import specifier to the feature ID it reaches, or `null` if
|
|
65
|
+
* the import doesn't land inside a bundled feature's directory. Pure —
|
|
66
|
+
* testable without a Project.
|
|
67
|
+
*/
|
|
68
|
+
export function extractFeatureId(spec: string, importingFilePath: string): string | null {
|
|
69
|
+
const packageMatch = spec.match(BUNDLED_FEATURES_PACKAGE_SPEC);
|
|
70
|
+
if (packageMatch) return packageMatch[1] ?? null;
|
|
71
|
+
|
|
72
|
+
if (!spec.startsWith(".")) return null;
|
|
73
|
+
const resolved = path.resolve(path.dirname(importingFilePath), spec);
|
|
74
|
+
// A relative import that never leaves the __tests__ dir (./helpers,
|
|
75
|
+
// ./fixtures) doesn't prove the feature itself is imported anywhere —
|
|
76
|
+
// only that its test has co-located helpers.
|
|
77
|
+
if (resolved.split(path.sep).includes("__tests__")) return null;
|
|
78
|
+
const dirMatch = `${resolved}/`.match(BUNDLED_FEATURES_SRC_DIR);
|
|
79
|
+
return dirMatch?.[1] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectFeatures(project: Project, paths: readonly string[]): Map<string, string> {
|
|
83
|
+
const features = new Map<string, string>();
|
|
84
|
+
for (const p of paths) {
|
|
85
|
+
const sf = project.getSourceFile(p) ?? project.addSourceFileAtPath(p);
|
|
86
|
+
const filePath = sf.getFilePath();
|
|
87
|
+
if (path.basename(filePath) !== "feature.ts") continue;
|
|
88
|
+
// Same derivation as extractFeatureId (producer/consumer must agree, or
|
|
89
|
+
// a nested feature dir orphans permanently — no import could ever
|
|
90
|
+
// satisfy a mismatched ID). Also filters out non-feature `feature.ts`
|
|
91
|
+
// fixtures (e.g. under __tests__) that don't sit in a bundled-features
|
|
92
|
+
// src dir.
|
|
93
|
+
const dirMatch = `${filePath}`.match(BUNDLED_FEATURES_SRC_DIR);
|
|
94
|
+
const featureId = dirMatch?.[1];
|
|
95
|
+
if (!featureId) continue;
|
|
96
|
+
features.set(featureId, path.relative(ROOT, filePath));
|
|
97
|
+
}
|
|
98
|
+
return features;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function collectImportedFeatureIds(project: Project, roots: readonly RepoRoot[]): Set<string> {
|
|
102
|
+
for (const p of scanFiles(INTEGRATION_SCAN, roots)) {
|
|
103
|
+
if (!project.getSourceFile(p)) project.addSourceFileAtPath(p);
|
|
104
|
+
}
|
|
105
|
+
const imported = new Set<string>();
|
|
106
|
+
for (const sf of project.getSourceFiles()) {
|
|
107
|
+
if (!/\.integration(\.test)?\.ts$/.test(sf.getFilePath())) continue;
|
|
108
|
+
for (const imp of sf.getDescendantsOfKind(SyntaxKind.ImportDeclaration)) {
|
|
109
|
+
const featureId = extractFeatureId(imp.getModuleSpecifierValue(), sf.getFilePath());
|
|
110
|
+
if (featureId) imported.add(featureId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return imported;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Baseline from the infra#436 measurement: features with no integration
|
|
118
|
+
* test reaching their directory at all — only covered (if at all) by unit
|
|
119
|
+
* tests (`feature.test.ts`). Pre-existing at the first sharp run, not
|
|
120
|
+
* introduced by this change. Backfilling is its own scope per feature, not
|
|
121
|
+
* a sweep.
|
|
122
|
+
*/
|
|
123
|
+
const ALLOWLIST: ReadonlySet<string> = new Set(["step-dispatcher"]);
|
|
124
|
+
|
|
125
|
+
export function computeOrphans(
|
|
126
|
+
features: ReadonlyMap<string, string>,
|
|
127
|
+
imported: ReadonlySet<string>,
|
|
128
|
+
allowlist: ReadonlySet<string> = ALLOWLIST,
|
|
129
|
+
): Array<{ name: string; file: string }> {
|
|
130
|
+
const orphans: Array<{ name: string; file: string }> = [];
|
|
131
|
+
for (const [id, file] of features) {
|
|
132
|
+
if (!imported.has(id) && !allowlist.has(id)) orphans.push({ name: id, file });
|
|
133
|
+
}
|
|
134
|
+
return orphans;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const check: RepoCheck = {
|
|
138
|
+
name: "Feature-Integration-Test Guard",
|
|
139
|
+
hint:
|
|
140
|
+
"Every feature needs a *.integration.ts that uses it in " +
|
|
141
|
+
"setupTestStack({ features: [...] }).",
|
|
142
|
+
run(roots) {
|
|
143
|
+
if (!roots.some((r) => r.kind === "framework")) {
|
|
144
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const project = new Project({
|
|
148
|
+
tsConfigFilePath: frameworkPackageTsConfigPath("bundled-features"),
|
|
149
|
+
skipAddingFilesFromTsConfig: true,
|
|
150
|
+
skipFileDependencyResolution: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const featurePaths = scanFiles(FEATURES_SCAN, roots);
|
|
154
|
+
const features = collectFeatures(project, featurePaths);
|
|
155
|
+
const imported = collectImportedFeatureIds(project, roots);
|
|
156
|
+
|
|
157
|
+
const violations: GuardViolation[] = [];
|
|
158
|
+
|
|
159
|
+
const stale = [...ALLOWLIST].filter((id) => imported.has(id));
|
|
160
|
+
for (const id of stale) {
|
|
161
|
+
violations.push({
|
|
162
|
+
file: features.get(id) ?? id,
|
|
163
|
+
line: 1,
|
|
164
|
+
message: `Allowlist entry "${id}" is now covered by an integration test — remove it from ALLOWLIST.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const orphan of computeOrphans(features, imported)) {
|
|
169
|
+
violations.push({
|
|
170
|
+
file: orphan.file,
|
|
171
|
+
line: 1,
|
|
172
|
+
message:
|
|
173
|
+
"Feature without an integration test — needs a *.integration.ts that uses it in setupTestStack({ features: [...] }).",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { violations, matchedFiles: featurePaths.length, notApplicable: false };
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (import.meta.main) {
|
|
182
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
183
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
184
|
+
}
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -325,7 +325,7 @@ function scanFile(sf: SourceFile): UnsafeSite[] {
|
|
|
325
325
|
export const guard: AstGuard = {
|
|
326
326
|
name: "HTML-Escape Guard",
|
|
327
327
|
scan: SCAN,
|
|
328
|
-
hint: "
|
|
328
|
+
hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
|
|
329
329
|
run(files) {
|
|
330
330
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
331
331
|
for (const sf of files) {
|