@cosmicdrift/kumiko-guards 0.334.0 → 0.335.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.334.0",
3
+ "version": "0.335.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.334.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.335.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -0,0 +1,29 @@
1
+ import * as path from "node:path";
2
+ import { type Node, SyntaxKind } from "ts-morph";
3
+
4
+ const FRAMEWORK_PACKAGE = "@cosmicdrift/kumiko-framework";
5
+ // Inside the framework monorepo itself the helpers are imported relatively,
6
+ // so a relative specifier counts when it resolves into the framework package.
7
+ const FRAMEWORK_SOURCE_DIR_MARKER = "/packages/framework/src/";
8
+
9
+ function isFrameworkModuleSpecifier(specifier: string, importingFile: string): boolean {
10
+ if (specifier === FRAMEWORK_PACKAGE || specifier.startsWith(`${FRAMEWORK_PACKAGE}/`)) {
11
+ return true;
12
+ }
13
+ if (!specifier.startsWith(".")) return false;
14
+ const resolved = path.resolve(path.dirname(importingFile), specifier);
15
+ return `${resolved}/`.includes(FRAMEWORK_SOURCE_DIR_MARKER);
16
+ }
17
+
18
+ // A text match on a function name lets a same-named local no-op clear an
19
+ // escape-hatch finding, so the identifier must resolve to an import of
20
+ // `exportedName` from the framework package. Aliased imports resolve too.
21
+ export function isFrameworkImportOf(node: Node, exportedName: string): boolean {
22
+ if (!node.isKind(SyntaxKind.Identifier)) return false;
23
+ const decls = node.getSymbol()?.getDeclarations() ?? [];
24
+ return decls.some((decl) => {
25
+ if (!decl.isKind(SyntaxKind.ImportSpecifier) || decl.getName() !== exportedName) return false;
26
+ const specifier = decl.getImportDeclaration().getModuleSpecifierValue();
27
+ return isFrameworkModuleSpecifier(specifier, decl.getSourceFile().getFilePath());
28
+ });
29
+ }
@@ -1,13 +1,5 @@
1
- /**
2
- * Spawned git calls get an allowlisted environment, never `...process.env`.
3
- * A caller running as a pre-push hook would otherwise inherit `GIT_DIR`/
4
- * `GIT_WORK_TREE` from git, which git prefers over `cwd` — so an inherited
5
- * environment would answer for the hook's repo instead of the path being
6
- * asked about. `HOME` stays in, so the global config is still read.
7
- *
8
- * An allowlist (not a blocklist) so a future git env var neither of us has
9
- * thought of yet is excluded by construction, not by omission.
10
- */
1
+ // Allowlist, not `...process.env`: git prefers an inherited GIT_DIR/GIT_WORK_TREE over cwd,
2
+ // so a pre-push hook's env would answer for the hook's repo. HOME stays so global config loads.
11
3
  export const GIT_ENV_KEYS = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME"] as const;
12
4
 
13
5
  /**
@@ -474,8 +474,13 @@ export function explainGuards(
474
474
 
475
475
  function guardKitVersion(): string {
476
476
  const pkgPath = resolve(import.meta.dir, "../../package.json");
477
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
478
- return pkg.version ?? "0.0.0";
477
+ // The version only decorates the banner; it must never abort a guard run.
478
+ try {
479
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
480
+ return pkg.version ?? "unknown";
481
+ } catch {
482
+ return "unknown";
483
+ }
479
484
  }
480
485
 
481
486
  /**
@@ -494,12 +499,7 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
494
499
  return undefined;
495
500
  }
496
501
 
497
- /**
498
- * Validates CLI args for one subcommand against the flags it actually
499
- * understands — every arg must match `known` exactly (not just a `--`-prefix
500
- * check, which let a single-dash typo or a stray positional through
501
- * unnoticed). An unknown arg must fail loud, never pass through silently.
502
- */
502
+ // Exact match, not a `--` prefix check: single-dash typos and positionals must not slip through.
503
503
  export function cliFlagsError(
504
504
  subcommand: string,
505
505
  argv: readonly string[],
@@ -108,8 +108,12 @@ export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "a
108
108
  const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
109
109
 
110
110
  // kumiko-platform deliberately returns no scan dirs (0 files) — its docs-samples tree needs its own allowlist review before this guard scans it (follow-up issue).
111
+ export function isSqlScanExcluded(root: RepoRoot): boolean {
112
+ return root.name === "kumiko-platform";
113
+ }
114
+
111
115
  export function sqlScanDirsFor(root: RepoRoot): readonly string[] {
112
- if (root.name === "kumiko-platform") return [];
116
+ if (isSqlScanExcluded(root)) return [];
113
117
  return sourceRootDirs(root).map((dir) => dir.slice(root.absPath.length).replace(/^\/+/, ""));
114
118
  }
115
119
 
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.335.0",
4
+ "type": "improvement",
5
+ "title": "Stricter guard detection for raw-sql, test-template-drift, framed extension sections, runtime-isolation and test-stack-drift",
6
+ "detail": "Stricter guard detection; app repos that carried an undetected pattern can now turn red.\n- `guard-raw-sql`: only kumiko-platform is skipped. An app repo whose declared `sourceRoots` all do not exist now fails as vacuous (0 files scanned) instead of passing.\n- `test-template-drift`: `...defineAppE2eConfig(...)` spread next to `fullyParallel`, `retries`, `timeout`, `workers` or `expect`, and shorthand `{ viewport }` / `{ deviceScaleFactor }`, are flagged.\n- `no-framed-extension-sections`: an `extensionSectionComponents` registry held in a variable, shorthand or spread is resolved.\n- `runtime-isolation`: entries declared in package.json `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` count as browser code.\n- `test-stack-drift`: `@no-server-stack:` only opts out when it sits in a comment, not in a string literal.\n- Direct `run-guards.ts` / `run-ui-guards.ts` / `run-repo-checks.ts` invocations exit with 1 instead of the failure count.",
7
+ "migration": "App repos that carried a previously undetected pattern can now fail their guards. Fix the reported sites; a vacuous raw-sql scan (all declared sourceRoots missing) must declare existing source roots."
8
+ },
9
+ {
10
+ "version": "0.335.0",
11
+ "type": "fix",
12
+ "title": "Escape-hatch guards accept only real framework imports, a corrupt cast baseline is an error, hints name kumiko-guards guards",
13
+ "detail": "The escape-hatch guards now only accept `declareEscapeHatch` and `withUnsafeRawGrant` imported from the framework itself; a local function or an import from another module with the same name no longer satisfies them. A corrupt `.kumiko-cast-baseline.json` is now an error instead of being treated as an empty baseline. Guard hints point at `kumiko-guards guards`."
14
+ },
2
15
  {
3
16
  "version": "0.332.0",
4
17
  "type": "fix",
@@ -48,6 +48,7 @@ import {
48
48
  buildSharedProject,
49
49
  filesForGuard,
50
50
  type GuardOutcome,
51
+ type GuardViolation,
51
52
  runStandalone,
52
53
  type ScanSpec,
53
54
  } from "./_lib/guard-kit";
@@ -546,26 +547,89 @@ function writeBaseline(all: readonly Site[]): void {
546
547
  // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
547
548
  // Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
548
549
  // obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
549
- function reportBaseline(all: readonly Site[]): void {
550
+ // Merge-conflict markers, truncated writes and hand edits all land here; the
551
+ // file is read once at the boundary so the comparison below can trust its shape.
552
+ function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
553
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
554
+ return Object.values(value).every(
555
+ (targets) =>
556
+ typeof targets === "object" &&
557
+ targets !== null &&
558
+ !Array.isArray(targets) &&
559
+ Object.values(targets).every((count) => typeof count === "number"),
560
+ );
561
+ }
562
+
563
+ type BaselineLoad =
564
+ | { kind: "missing" }
565
+ | { kind: "format-drift"; format: unknown }
566
+ | { kind: "invalid"; reason: string }
567
+ | { kind: "ok"; baseline: Baseline };
568
+
569
+ export function loadBaseline(file: string): BaselineLoad {
570
+ if (!existsSync(file)) return { kind: "missing" };
571
+ let parsed: unknown;
572
+ try {
573
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
574
+ } catch (error) {
575
+ return { kind: "invalid", reason: error instanceof Error ? error.message : String(error) };
576
+ }
577
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
578
+ return { kind: "invalid", reason: "expected a JSON object" };
579
+ }
580
+ const record: Record<string, unknown> = { ...parsed };
581
+ if (record["format"] !== BASELINE_FORMAT_VERSION) {
582
+ return { kind: "format-drift", format: record["format"] };
583
+ }
584
+ const { generated, totalSuspect, perFile } = record;
585
+ if (
586
+ typeof generated !== "string" ||
587
+ typeof totalSuspect !== "number" ||
588
+ !isCountsByFile(perFile)
589
+ ) {
590
+ return {
591
+ kind: "invalid",
592
+ reason: "expected { format, generated: string, totalSuspect: number, perFile: object }",
593
+ };
594
+ }
595
+ return {
596
+ kind: "ok",
597
+ baseline: { format: BASELINE_FORMAT_VERSION, generated, totalSuspect, perFile },
598
+ };
599
+ }
600
+
601
+ export function reportBaseline(
602
+ all: readonly Site[],
603
+ baselineFile: string = baselinePath,
604
+ ): GuardViolation[] {
550
605
  const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
551
606
  const repoLocalTotal = totalOf(repoLocalSuspects);
552
607
 
553
- if (!existsSync(baselinePath)) {
608
+ const loaded = loadBaseline(baselineFile);
609
+ if (loaded.kind === "missing") {
554
610
  console.log(
555
611
  "\n No baseline found. Freeze one with `--write-baseline` first — warning, no fail until then.",
556
612
  );
557
- return;
613
+ return [];
558
614
  }
559
-
560
- const rawBaseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as Partial<Baseline>;
561
- if (rawBaseline.format !== BASELINE_FORMAT_VERSION) {
615
+ if (loaded.kind === "invalid") {
616
+ // A corrupt baseline must not read as "no regressions".
617
+ return [
618
+ {
619
+ file: baselineFile,
620
+ line: 1,
621
+ message: `Cannot read cast baseline ${baselineFile}: ${loaded.reason}. Fix the file (e.g. resolve merge-conflict markers) or regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
622
+ },
623
+ ];
624
+ }
625
+ if (loaded.kind === "format-drift") {
562
626
  console.log(
563
- `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${rawBaseline.format ?? "<missing>"}.`,
627
+ `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
564
628
  );
565
629
  console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
566
- return;
630
+ return [];
567
631
  }
568
- const baseline = rawBaseline as Baseline;
632
+ const { baseline } = loaded;
569
633
 
570
634
  type Regression = { file: string; target: string; baseline: number; current: number };
571
635
  const regressions: Regression[] = [];
@@ -602,7 +666,7 @@ function reportBaseline(all: readonly Site[]): void {
602
666
  " 3. If a cleanup reduction in one file offsets an increase in another: " +
603
667
  "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
604
668
  );
605
- return;
669
+ return [];
606
670
  }
607
671
 
608
672
  console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
@@ -611,17 +675,21 @@ function reportBaseline(all: readonly Site[]): void {
611
675
  ` ✓ ${reduced} suspect cast(s) reduced since baseline. Run \`--write-baseline\` if appropriate.`,
612
676
  );
613
677
  }
678
+ return [];
614
679
  }
615
680
 
616
681
  function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
617
682
  const { all, scanned } = scanCasts(files);
618
683
  reportCasts(all, scanned);
619
684
  reportUnknownReasons(all);
620
- if (compareBaseline) reportBaseline(all);
621
- else console.log("\n Baseline comparison skipped (--no-baseline).");
685
+ if (!compareBaseline) {
686
+ console.log("\n Baseline comparison skipped (--no-baseline).");
687
+ return { violations: [] };
688
+ }
622
689
  // WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
623
- // unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken.
624
- return { violations: [] };
690
+ // unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken. Nur
691
+ // eine unlesbare Baseline schlägt fehl.
692
+ return { violations: reportBaseline(all) };
625
693
  }
626
694
 
627
695
  export const guard: AstGuard = {
@@ -133,6 +133,14 @@ function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): nu
133
133
  return scanned;
134
134
  }
135
135
 
136
+ // A bare substring match would accept the entry inside a TOML comment or under
137
+ // another key such as coveragePathIgnorePatterns, which leaves `bun test` unguarded.
138
+ function ignoresRealTests(bunfigToml: string): boolean {
139
+ const withoutComments = bunfigToml.replace(/^\s*#.*$/gm, "");
140
+ const match = /^\s*pathIgnorePatterns\s*=\s*\[([^\]]*)\]/m.exec(withoutComments);
141
+ return match?.[1]?.includes(`"${REAL_TEST_IGNORE_ENTRY}"`) ?? false;
142
+ }
143
+
136
144
  // The generated bunfig template (packages/testing/src/bunfig.ts) is what app
137
145
  // and library repos consume via `kumiko-testing bunfig` — the framework repo
138
146
  // hand-maintains its own root bunfig*.toml files and doesn't run this
@@ -150,7 +158,7 @@ function bunfigMissingRealExclusion(root: RepoRoot, findings: TextLineFinding[])
150
158
  continue;
151
159
  }
152
160
  scanned++;
153
- if (!content.includes(REAL_TEST_IGNORE_ENTRY)) {
161
+ if (!ignoresRealTests(content)) {
154
162
  findings.push({
155
163
  file: rel,
156
164
  line: 1,
@@ -68,9 +68,11 @@ import { type ScanSpec, scanFiles, scanRoots } from "./_lib/scan-scope";
68
68
  import {
69
69
  classify,
70
70
  computeClientReachablePaths,
71
+ createDirectiveClassifier,
71
72
  findRuntimeIsolationViolations,
72
73
  isClientEntryPath,
73
74
  type Runtime,
75
+ readDeclaredClientEntries,
74
76
  } from "./runtime-isolation-classify";
75
77
 
76
78
  const SCAN: ScanSpec = { scope: "source", extensions: ["ts", "tsx"] };
@@ -141,11 +143,15 @@ function scanRoot(root: RepoRoot): {
141
143
  return !fp.includes("/node_modules/") && !fp.includes("/dist/");
142
144
  });
143
145
 
146
+ const declaredClientEntries = readDeclaredClientEntries(root.absPath);
144
147
  const workspaceCache = new Map<string, Runtime | null>();
148
+ const directiveOf = createDirectiveClassifier();
145
149
  const clientReachable = computeClientReachablePaths(scannedFiles, (sf) => {
146
150
  const rel = path.relative(root.absPath, sf.getFilePath());
147
- if (isClientEntryPath(rel)) return true;
148
- return classify(sf.getFilePath(), root.absPath, workspaceCache) === "client";
151
+ if (isClientEntryPath(rel, declaredClientEntries)) return true;
152
+ return (
153
+ classify(sf.getFilePath(), root.absPath, workspaceCache, undefined, directiveOf) === "client"
154
+ );
149
155
  });
150
156
 
151
157
  const { violations: allViolations, outsideRoot } = findRuntimeIsolationViolations(
@@ -153,6 +159,7 @@ function scanRoot(root: RepoRoot): {
153
159
  root.absPath,
154
160
  workspaceCache,
155
161
  clientReachable,
162
+ directiveOf,
156
163
  );
157
164
 
158
165
  return { allViolations, outsideRoot, scannedFiles: scannedFiles.length };
package/src/cli.ts CHANGED
@@ -45,6 +45,10 @@ async function main(): Promise<void> {
45
45
  process.exit(1);
46
46
  }
47
47
  const flags = process.argv.slice(3);
48
+ if (subcommand !== undefined && (flags.includes("--help") || flags.includes("-h"))) {
49
+ printHelp();
50
+ process.exit(0);
51
+ }
48
52
 
49
53
  if (subcommand === "list") {
50
54
  const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Usage:
15
15
  * bun guards/guard-access-denied-test.ts
16
- * Baseline: bun guards/run-guards.ts --write-security-baseline
16
+ * Baseline: kumiko-guards guards --write-security-baseline
17
17
  */
18
18
 
19
19
  import * as path from "node:path";
@@ -117,7 +117,7 @@ export const guard: AstGuard = {
117
117
  name: "Access-Denied-Test Guard",
118
118
  scan: SCAN,
119
119
  security: true,
120
- hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `bun guards/run-guards.ts --write-security-baseline`.",
120
+ hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `kumiko-guards guards --write-security-baseline`.",
121
121
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
122
122
  // consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
123
123
  const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
@@ -34,12 +34,11 @@ const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
34
34
  const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
35
35
 
36
36
  // Allowed callers: migration runners (sample-local, src/ and bin/) +
37
- // admin scripts + the definition itself + this guard script.
37
+ // admin scripts + the definition itself.
38
38
  const ALLOWLIST: readonly RegExp[] = [
39
39
  /^samples\/[^/]+\/src\/migration\//,
40
40
  /^scripts\/migrations\//,
41
41
  /^packages\/framework\/src\/event-store\/admin-api\.ts$/,
42
- /^scripts\/guard-admin-api\.ts$/,
43
42
  ];
44
43
 
45
44
  const GUARDED_CALLS = new Set(["appendRaw", "appendRawBatch"]);
@@ -40,8 +40,8 @@ export const guard: AstGuard = {
40
40
  name: "App-Feature-Structure Guard (App-Repos)",
41
41
  scan: SCAN,
42
42
  hint:
43
- "Konvention: feature.ts nur Registrierung, Screens unter web/ (eine Datei pro Screen), Handler unter handlers/, " +
44
- `Domain-Logik unter lib/. Custom-Screens brauchen // ${IGNORE_TAG} <Grund> (deklarative Screen-Typen sind der Default).`,
43
+ "Convention: feature.ts is registration only, screens live under web/ (one file per screen), handlers under handlers/, " +
44
+ `domain logic under lib/. Custom screens need // ${IGNORE_TAG} <reason> (declarative screen types are the default).`,
45
45
  run(files: readonly SourceFile[]) {
46
46
  const violations: GuardViolation[] = [];
47
47
  for (const sf of files) {
@@ -56,7 +56,7 @@ export const guard: AstGuard = {
56
56
  file: filePath,
57
57
  line: 1,
58
58
  message:
59
- "web-Monolith am Feature-Root — Screens/Client-Def gehören unter web/ (index.ts + eine Datei pro Screen)",
59
+ "web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
60
60
  });
61
61
  }
62
62
  }
@@ -74,7 +74,7 @@ export const guard: AstGuard = {
74
74
  violations.push({
75
75
  file: filePath,
76
76
  line: 1,
77
- message: "JSX-Komponente am Feature-Root — unter web/ verschieben",
77
+ message: "JSX component at feature root — move it under web/",
78
78
  });
79
79
  }
80
80
 
@@ -85,7 +85,7 @@ export const guard: AstGuard = {
85
85
  violations.push({
86
86
  file: filePath,
87
87
  line: 1,
88
- message: `feature.ts hat ${lines} Zeilen (max ${MAX_FEATURE_TS_LINES}) — Handler nach handlers/, Schemas nach schema/, Logik nach lib/`,
88
+ message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
89
89
  });
90
90
  }
91
91
  }
@@ -99,11 +99,13 @@ export const guard: AstGuard = {
99
99
  // Nur r.screen-Kontext: das umgebende Call-Target muss auf .screen enden.
100
100
  const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
101
101
  if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
102
+ // Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
103
+ if (prop.getParent() !== call.getArguments()[0]) continue;
102
104
  if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
103
105
  violations.push({
104
106
  file: filePath,
105
107
  line: prop.getStartLineNumber(),
106
- message: `r.screen type:"custom" ohne Allowlist-Tag — deklarativen Screen-Typ nutzen oder // ${IGNORE_TAG} <Grund>`,
108
+ message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
107
109
  });
108
110
  }
109
111
  }
@@ -118,7 +118,7 @@ export const guard: AstGuard = {
118
118
  hint:
119
119
  "Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
120
120
  'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch` on the line above. ' +
121
- "Local bindings named fetch are flagged (false positive — unblock via that marker or ALLOWLIST in infra/guards/guard-direct-fetch.ts). " +
121
+ "Local bindings named fetch are flagged (false positive — unblock via that marker). " +
122
122
  'Known gap: bracket access (globalThis["fetch"](...)).',
123
123
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
124
124
  const violations: Array<{ file: string; line: number; message: string }> = [];
@@ -60,6 +60,10 @@ const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
60
60
  // justification, not an error-reason code.
61
61
  const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
62
62
 
63
+ // Calls whose object-literal argument carries a prose `reason` for an
64
+ // access grant / escape hatch.
65
+ const ACCESS_DECLARATION_CALLS = new Set(["declareEscapeHatch", "withUnsafeRawGrant"]);
66
+
63
67
  interface Violation {
64
68
  readonly file: string;
65
69
  readonly line: number;
@@ -135,8 +139,8 @@ function scanFile(sf: SourceFile): Violation[] {
135
139
  }
136
140
 
137
141
  // A `reason` PropertyAssignment whose object literal is the initializer of
138
- // an `openToAll` / `escapeHatch` PropertyAssignment, or the sole argument
139
- // object of a bare `declareEscapeHatch(...)` call, is an access-declaration
142
+ // an `openToAll` / `escapeHatch` PropertyAssignment, or an argument
143
+ // object of a bare `declareEscapeHatch(...)` / `withUnsafeRawGrant(...)` call, is an access-declaration
140
144
  // justification, not an error-reason code.
141
145
  export function isAccessDeclarationReason(prop: Node): boolean {
142
146
  const objectLiteral = prop.getParent();
@@ -147,7 +151,7 @@ export function isAccessDeclarationReason(prop: Node): boolean {
147
151
  }
148
152
  if (owner?.isKind(SyntaxKind.CallExpression)) {
149
153
  const callee = owner.getExpression();
150
- return callee.isKind(SyntaxKind.Identifier) && callee.getText() === "declareEscapeHatch";
154
+ return callee.isKind(SyntaxKind.Identifier) && ACCESS_DECLARATION_CALLS.has(callee.getText());
151
155
  }
152
156
  return false;
153
157
  }
@@ -46,10 +46,11 @@
46
46
  * unresolved and the R2/R3 finding names that explicitly. The declaration
47
47
  * does not propagate upward: it covers escalations inside that function's
48
48
  * own body, not the function it is nested inside. This detection is purely
49
- * lexical — the guard matches on the name `declareEscapeHatch`, not on where
50
- * it was imported from, so a same-named local function clears just as well;
51
- * consistent with `escapeHatch:` itself, which is likewise never checked for
52
- * origin. Referenced-by-variable functions, spread options, computed/string
49
+ * lexical except for origin: `declareEscapeHatch` (and `withUnsafeRawGrant`
50
+ * for R2) must resolve to an import from `@cosmicdrift/kumiko-framework`
51
+ * (or, inside the framework package, a relative import into its `src`), so a
52
+ * same-named local function does not clear. `escapeHatch:` itself is a plain
53
+ * property and is never checked for origin. Referenced-by-variable functions, spread options, computed/string
53
54
  * keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
54
55
  * value are conservatively not recognized (miss, don't falsely clear).
55
56
  *
@@ -68,7 +69,7 @@
68
69
  *
69
70
  * Usage:
70
71
  * bun guards/guard-escape-hatch-declared.ts
71
- * Baseline: bun guards/run-guards.ts --write-security-baseline
72
+ * Baseline: kumiko-guards guards --write-security-baseline
72
73
  */
73
74
  import * as path from "node:path";
74
75
  import {
@@ -78,6 +79,7 @@ import {
78
79
  type SourceFile,
79
80
  SyntaxKind,
80
81
  } from "ts-morph";
82
+ import { isFrameworkImportOf } from "./_lib/framework-import";
81
83
  import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
82
84
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
83
85
 
@@ -230,7 +232,7 @@ function isExplicitUnsafeRawGrant(call: Node): boolean {
230
232
  const receiver = expr.getExpression();
231
233
  return (
232
234
  receiver.isKind(SyntaxKind.CallExpression) &&
233
- receiver.getExpression().getText() === "withUnsafeRawGrant"
235
+ isFrameworkImportOf(receiver.getExpression(), "withUnsafeRawGrant")
234
236
  );
235
237
  }
236
238
 
@@ -387,9 +389,7 @@ function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
387
389
  const expr = stmt.getExpression();
388
390
  if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
389
391
  const callee = expr.getExpression();
390
- if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
391
- return undefined;
392
- }
392
+ if (!isFrameworkImportOf(callee, "declareEscapeHatch")) return undefined;
393
393
  const args = expr.getArguments();
394
394
  const arg = args[0];
395
395
  if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
@@ -471,7 +471,8 @@ function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
471
471
  body = fn.getBody();
472
472
  }
473
473
  if (!body?.isKind(SyntaxKind.Block)) return false;
474
- return body.getStatements().some((stmt) => isValidDeclareEscapeHatchCall(stmt));
474
+ const firstStatement = body.getStatements()[0];
475
+ return firstStatement !== undefined && isValidDeclareEscapeHatchCall(firstStatement);
475
476
  }
476
477
 
477
478
  function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
@@ -680,7 +681,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
680
681
  name: "Escape-Hatch-Declared Guard",
681
682
  scan: SCAN,
682
683
  security: true,
683
- 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`',
684
+ 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: `kumiko-guards guards --write-security-baseline`',
684
685
  run(files) {
685
686
  const violations: GuardViolation[] = [
686
687
  ...findGenericReasonCalls(files, opts.root).map((f) => ({
@@ -63,7 +63,6 @@ function collectUsedKeys(sf: SourceFile): UsedKey[] {
63
63
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
64
64
  const exprText = call.getExpression().getText();
65
65
  if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
66
- if (exprText === "test" || exprText === "expect") continue;
67
66
  const args = call.getArguments();
68
67
  if (args.length === 0) continue;
69
68
  const first = args[0];
@@ -92,7 +92,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
92
92
  violations.push({
93
93
  file: sf.getFilePath(),
94
94
  line: prop.getStartLineNumber(),
95
- message: `[${locale}] verbotener Begriff "${hit}" in Übersetzungswert — nutze "${rule.preferred}" (fw#2200).`,
95
+ message: `[${locale}] forbidden term "${hit}" in translation value — use "${rule.preferred}" (fw#2200).`,
96
96
  });
97
97
  }
98
98
  }
@@ -104,7 +104,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
104
104
  export const guard: AstGuard = {
105
105
  name: "i18n-Locale-Terminology Guard",
106
106
  scan: SCAN,
107
- hint: "DE: Mandant statt Tenant/Organisation; ES: Organización statt tenant-Loanword. Rollen wie TenantAdmin sind OK.",
107
+ hint: "DE: Mandant instead of Tenant/Organisation; ES: Organización instead of the tenant loanword. Roles like TenantAdmin are fine.",
108
108
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
109
109
  const violations = findViolations(files)
110
110
  .map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
@@ -29,6 +29,7 @@
29
29
  import { dirname, resolve } from "node:path";
30
30
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
31
31
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
32
+ import { escapeRegExp } from "./_lib/handler-name-forms";
32
33
  import { hasIgnoreTag } from "./_lib/ignore-tag";
33
34
 
34
35
  // isTestFile() classifies by regex on the file's own path, not by which within-glob matched it, so lib/ vs. test classification stays correct regardless of scan-scope resolution.
@@ -116,16 +117,20 @@ function bodyTextWithoutImports(testSf: SourceFile): string {
116
117
  }
117
118
 
118
119
  function referencesName(testSf: SourceFile, name: string): boolean {
119
- return new RegExp(`\\b${name}\\b`).test(bodyTextWithoutImports(testSf));
120
+ // `\b` does not treat `$` as a word character, so identifiers like `format$`
121
+ // need explicit identifier-char lookarounds.
122
+ return new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`).test(
123
+ bodyTextWithoutImports(testSf),
124
+ );
120
125
  }
121
126
 
122
127
  export const guard: AstGuard = {
123
128
  name: "Lib-Test-Coverage Guard (App-Repos)",
124
129
  scan: SCAN,
125
130
  hint:
126
- "Jede lib-Datei mit exportierter Funktion braucht einen Test, der aus dem " +
127
- "Modul importiert und jede Funktion namentlich referenziert. IO-Loader, die " +
128
- `ein Integrationstest deckt: // ${IGNORE_TAG} <Grund, z.B. integration-covered via …>`,
131
+ "Every lib file with an exported function needs a test that imports from the " +
132
+ "module and references each function by name. IO loaders that an " +
133
+ `integration test covers: // ${IGNORE_TAG} <reason, e.g. integration-covered via …>`,
129
134
  run(files: readonly SourceFile[]) {
130
135
  const violations: GuardViolation[] = [];
131
136
  const testFiles: SourceFile[] = [];
@@ -145,7 +150,7 @@ export const guard: AstGuard = {
145
150
  violations.push({
146
151
  file: sf.getFilePath(),
147
152
  line: 1,
148
- message: `Kein Test importiert dieses lib-Modul (${relevant.length} exportierte Funktion(en) ungetestet)`,
153
+ message: `No test imports this lib module (${relevant.length} exported function(s) untested)`,
149
154
  });
150
155
  continue;
151
156
  }
@@ -155,7 +160,7 @@ export const guard: AstGuard = {
155
160
  violations.push({
156
161
  file: sf.getFilePath(),
157
162
  line: e.node.getStartLineNumber(),
158
- message: `Exportierte Funktion "${e.name}" wird in keinem verknüpften Test namentlich referenziert`,
163
+ message: `Exported function "${e.name}" is not referenced by name in any linked test`,
159
164
  });
160
165
  }
161
166
  }