@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.
Files changed (52) hide show
  1. package/package.json +5 -2
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +193 -0
  16. package/src/guard-escape-hatch-declared.ts +155 -35
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. 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 and outside a handler/hook that lexically declares
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, or a handler/hook that lexically declares
14
- * `escapeHatch`.
15
- * R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, or a declared
16
- * `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, given a
17
- * placeholder reason literal hard-fails everywhere, not baselined.
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 an inline `handler` function, or in an options object
26
- * passed to `r.hook`/`writeHandler`/`queryHandler`/`streamHandler` alongside
27
- * the handler function argument. Referenced-by-variable functions, spread
28
- * options, computed/string keys, and non-literal escapeHatch values are
29
- * conservatively not recognized (miss, don't falsely clear).
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. Known false-negatives:
33
- * multi-hop aliasing, `ctx["db"]` through an intermediate variable, and a
34
- * TenantDb/system-identity handed to another function across file
35
- * boundaries all conservative (miss, don't falsely flag). R5 additionally
36
- * misses `unsafeAllTenants` given via an identifier, a ternary, `false`, or
37
- * `undefined`, and an options object passed by variable reference or spread
38
- * rather than as a literal call argument — all conservative (miss, don't
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 { type Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
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 (isInsideEscapeHatchDeclaredFunction(call)) continue;
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
- const nameNode = fn.getNameNode();
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)) return true;
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: 'Escape-Hatch deklarieren (r.systemScope() auf der Feature-Definition, .job.ts/r.job(...) fuer Jobs, oder { escapeHatch: { reason: "..." } } auf dem Handler oder Hook) oder den ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants-Zugriff entfernen. Baseline nach bewusster Reduktion: `bun guards/run-guards.ts --write-security-baseline`',
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) => ({
@@ -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 ohne expect() oder mit Tautologieechtes Verhalten prüfen, nicht Existenz.",
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
+ }
@@ -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: "Interpolation in HTML-Template-Literal escapen: escapeHtml()/escapeHtmlAttr() aus @cosmicdrift/kumiko-headless. Vorgerendertes HTML per `*Html`-Namen kennzeichnen; statische Copy-Tabellen `as const` typen; bewusste Ausnahme mit `// html-ok: <warum>`.",
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) {