@cosmicdrift/kumiko-guards 0.334.0 → 0.336.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/framework-import.ts +29 -0
  3. package/src/_lib/git-env.ts +2 -10
  4. package/src/_lib/guard-kit.ts +12 -8
  5. package/src/_lib/scan-scope.ts +1 -1
  6. package/src/_lib/sql-inventory.ts +5 -1
  7. package/src/changes.json +26 -0
  8. package/src/check-as-casts.ts +89 -14
  9. package/src/check-real-provider-isolation.ts +9 -1
  10. package/src/check-runtime-isolation.ts +11 -4
  11. package/src/check-secret-literals.ts +18 -13
  12. package/src/cli.ts +4 -0
  13. package/src/guard-access-denied-test.ts +2 -2
  14. package/src/guard-admin-api.ts +1 -2
  15. package/src/guard-app-feature-structure.ts +8 -6
  16. package/src/guard-direct-fetch.ts +1 -1
  17. package/src/guard-error-reasons.ts +7 -3
  18. package/src/guard-escape-hatch-declared.ts +25 -13
  19. package/src/guard-fake-tests.ts +32 -25
  20. package/src/guard-i18n-keys.ts +0 -1
  21. package/src/guard-i18n-locale-terminology.ts +3 -3
  22. package/src/guard-lib-test-coverage.ts +11 -6
  23. package/src/guard-no-custom-primitives.ts +3 -2
  24. package/src/guard-no-framed-extension-sections.ts +90 -26
  25. package/src/guard-no-inline-styles.ts +9 -3
  26. package/src/guard-no-logic-in-views.ts +8 -2
  27. package/src/guard-no-raw-hooks.ts +11 -4
  28. package/src/guard-open-to-all-reason.ts +2 -2
  29. package/src/guard-primitives-discipline.ts +3 -2
  30. package/src/guard-raw-classname.ts +8 -2
  31. package/src/guard-raw-sql.ts +9 -3
  32. package/src/guard-section-fields-raw.ts +81 -9
  33. package/src/guard-test-stack-drift.ts +11 -1
  34. package/src/guard-test-template-drift.ts +48 -2
  35. package/src/guard-test-timeouts.ts +21 -2
  36. package/src/guard-thin-wrappers.ts +2 -2
  37. package/src/guard-upgrade-state.ts +7 -4
  38. package/src/pre-push.sh +2 -2
  39. package/src/run-guards.ts +6 -1
  40. package/src/run-repo-checks.ts +1 -1
  41. package/src/run-ui-guards.ts +1 -1
  42. package/src/runtime-isolation-classify.ts +147 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.334.0",
3
+ "version": "0.336.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.336.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
  /**
@@ -125,6 +125,10 @@ export function relFromRepoRoot(
125
125
  return filePath.slice(filePath.indexOf(marker));
126
126
  }
127
127
 
128
+ export function relativeToCwd(absolutePath: string): string {
129
+ return pathRelative(process.cwd(), absolutePath);
130
+ }
131
+
128
132
  export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): boolean {
129
133
  return allowlist.some((re) => re.test(relPath));
130
134
  }
@@ -474,8 +478,13 @@ export function explainGuards(
474
478
 
475
479
  function guardKitVersion(): string {
476
480
  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";
481
+ // The version only decorates the banner; it must never abort a guard run.
482
+ try {
483
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
484
+ return pkg.version ?? "unknown";
485
+ } catch {
486
+ return "unknown";
487
+ }
479
488
  }
480
489
 
481
490
  /**
@@ -494,12 +503,7 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
494
503
  return undefined;
495
504
  }
496
505
 
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
- */
506
+ // Exact match, not a `--` prefix check: single-dash typos and positionals must not slip through.
503
507
  export function cliFlagsError(
504
508
  subcommand: string,
505
509
  argv: readonly string[],
@@ -164,7 +164,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
164
164
  return { root, files, sourceSurface: sourceSurfaceHits.length };
165
165
  }
166
166
 
167
- function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
167
+ export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
168
168
  if (spec.kinds) return spec.kinds.includes(root.kind);
169
169
  return root.kind !== "tooling";
170
170
  }
@@ -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,30 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "improvement",
5
+ "title": "Guards reject --guard without --write-baseline and catch more raw-form-HTML, locale and timeout cases",
6
+ "detail": "Guards are stricter and more consistent: `guards --guard=<name>` without `--write-baseline` is rejected instead of silently running every guard, the raw-form-HTML check also catches tags after `=`, `[` or in spaceless ternaries, the German locale check flags lowercase \"organisation\", `test-timeouts` flags `setDefaultTimeout` and `describe.configure({ timeout })`, the no-framed-extension-sections guard correlates registry keys and usages by resolved value, and the fake-tests helper lookup is scope-aware. UI guards report repo-relative file paths. The pre-push worktree detection compares absolute git dirs."
7
+ },
8
+ {
9
+ "version": "0.336.0",
10
+ "type": "improvement",
11
+ "title": "secret-literal checks all fallbacks on a line; as-casts audit is quiet inside the shared runner",
12
+ "detail": "`secret-literal` now inspects every `?? \"literal\"` fallback on a line instead of only the first, so a hardcoded secret behind a trivial first fallback is flagged. The `as-casts` audit prints its per-site listings only when run standalone; inside the shared runner it prints just the counts and the baseline verdict.",
13
+ "migration": "A repo with a hardcoded secret fallback hidden behind an earlier trivial fallback on the same line can now fail the secret-literal guard. Replace the literal with a hard failure on a missing env value."
14
+ },
15
+ {
16
+ "version": "0.335.0",
17
+ "type": "improvement",
18
+ "title": "Stricter guard detection for raw-sql, test-template-drift, framed extension sections, runtime-isolation and test-stack-drift",
19
+ "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.",
20
+ "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."
21
+ },
22
+ {
23
+ "version": "0.335.0",
24
+ "type": "fix",
25
+ "title": "Escape-hatch guards accept only real framework imports, a corrupt cast baseline is an error, hints name kumiko-guards guards",
26
+ "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`."
27
+ },
2
28
  {
3
29
  "version": "0.332.0",
4
30
  "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";
@@ -404,6 +405,11 @@ function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
404
405
  return byCat;
405
406
  }
406
407
 
408
+ // The per-site listings are advisory and long; inside the shared runner (every consumer's
409
+ // `bun run check`) they would bury the output of guards that can fail, so only a standalone
410
+ // run of this file prints them.
411
+ const PRINT_SITE_LISTINGS = import.meta.main;
412
+
407
413
  function reportCasts(all: readonly Site[], scanned: number): void {
408
414
  const byCat = groupByCategory(all);
409
415
  console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
@@ -411,6 +417,8 @@ function reportCasts(all: readonly Site[], scanned: number): void {
411
417
  const count = byCat.get(c)?.length ?? 0;
412
418
  console.log(` ${c.padEnd(18)} ${count}`);
413
419
  }
420
+ // skip: site listings are printed only on a standalone run of this guard
421
+ if (!PRINT_SITE_LISTINGS) return;
414
422
 
415
423
  const suspects = CATS.filter((c) => c.startsWith("suspect-"));
416
424
 
@@ -546,26 +554,89 @@ function writeBaseline(all: readonly Site[]): void {
546
554
  // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
547
555
  // Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
548
556
  // obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
549
- function reportBaseline(all: readonly Site[]): void {
557
+ // Merge-conflict markers, truncated writes and hand edits all land here; the
558
+ // file is read once at the boundary so the comparison below can trust its shape.
559
+ function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
560
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
561
+ return Object.values(value).every(
562
+ (targets) =>
563
+ typeof targets === "object" &&
564
+ targets !== null &&
565
+ !Array.isArray(targets) &&
566
+ Object.values(targets).every((count) => typeof count === "number"),
567
+ );
568
+ }
569
+
570
+ type BaselineLoad =
571
+ | { kind: "missing" }
572
+ | { kind: "format-drift"; format: unknown }
573
+ | { kind: "invalid"; reason: string }
574
+ | { kind: "ok"; baseline: Baseline };
575
+
576
+ export function loadBaseline(file: string): BaselineLoad {
577
+ if (!existsSync(file)) return { kind: "missing" };
578
+ let parsed: unknown;
579
+ try {
580
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
581
+ } catch (error) {
582
+ return { kind: "invalid", reason: error instanceof Error ? error.message : String(error) };
583
+ }
584
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
585
+ return { kind: "invalid", reason: "expected a JSON object" };
586
+ }
587
+ const record: Record<string, unknown> = { ...parsed };
588
+ if (record["format"] !== BASELINE_FORMAT_VERSION) {
589
+ return { kind: "format-drift", format: record["format"] };
590
+ }
591
+ const { generated, totalSuspect, perFile } = record;
592
+ if (
593
+ typeof generated !== "string" ||
594
+ typeof totalSuspect !== "number" ||
595
+ !isCountsByFile(perFile)
596
+ ) {
597
+ return {
598
+ kind: "invalid",
599
+ reason: "expected { format, generated: string, totalSuspect: number, perFile: object }",
600
+ };
601
+ }
602
+ return {
603
+ kind: "ok",
604
+ baseline: { format: BASELINE_FORMAT_VERSION, generated, totalSuspect, perFile },
605
+ };
606
+ }
607
+
608
+ export function reportBaseline(
609
+ all: readonly Site[],
610
+ baselineFile: string = baselinePath,
611
+ ): GuardViolation[] {
550
612
  const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
551
613
  const repoLocalTotal = totalOf(repoLocalSuspects);
552
614
 
553
- if (!existsSync(baselinePath)) {
615
+ const loaded = loadBaseline(baselineFile);
616
+ if (loaded.kind === "missing") {
554
617
  console.log(
555
618
  "\n No baseline found. Freeze one with `--write-baseline` first — warning, no fail until then.",
556
619
  );
557
- return;
620
+ return [];
558
621
  }
559
-
560
- const rawBaseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as Partial<Baseline>;
561
- if (rawBaseline.format !== BASELINE_FORMAT_VERSION) {
622
+ if (loaded.kind === "invalid") {
623
+ // A corrupt baseline must not read as "no regressions".
624
+ return [
625
+ {
626
+ file: baselineFile,
627
+ line: 1,
628
+ 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\`.`,
629
+ },
630
+ ];
631
+ }
632
+ if (loaded.kind === "format-drift") {
562
633
  console.log(
563
- `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${rawBaseline.format ?? "<missing>"}.`,
634
+ `\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
564
635
  );
565
636
  console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
566
- return;
637
+ return [];
567
638
  }
568
- const baseline = rawBaseline as Baseline;
639
+ const { baseline } = loaded;
569
640
 
570
641
  type Regression = { file: string; target: string; baseline: number; current: number };
571
642
  const regressions: Regression[] = [];
@@ -602,7 +673,7 @@ function reportBaseline(all: readonly Site[]): void {
602
673
  " 3. If a cleanup reduction in one file offsets an increase in another: " +
603
674
  "run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
604
675
  );
605
- return;
676
+ return [];
606
677
  }
607
678
 
608
679
  console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
@@ -611,17 +682,21 @@ function reportBaseline(all: readonly Site[]): void {
611
682
  ` ✓ ${reduced} suspect cast(s) reduced since baseline. Run \`--write-baseline\` if appropriate.`,
612
683
  );
613
684
  }
685
+ return [];
614
686
  }
615
687
 
616
688
  function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
617
689
  const { all, scanned } = scanCasts(files);
618
690
  reportCasts(all, scanned);
619
691
  reportUnknownReasons(all);
620
- if (compareBaseline) reportBaseline(all);
621
- else console.log("\n Baseline comparison skipped (--no-baseline).");
692
+ if (!compareBaseline) {
693
+ console.log("\n Baseline comparison skipped (--no-baseline).");
694
+ return { violations: [] };
695
+ }
622
696
  // WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
623
- // unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken.
624
- return { violations: [] };
697
+ // unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken. Nur
698
+ // eine unlesbare Baseline schlägt fehl.
699
+ return { violations: reportBaseline(all) };
625
700
  }
626
701
 
627
702
  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,
@@ -64,13 +64,15 @@ import {
64
64
  runRepoChecks,
65
65
  } from "./_lib/guard-kit";
66
66
  import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
67
- import { type ScanSpec, scanFiles, scanRoots } from "./_lib/scan-scope";
67
+ import { keepsRootKind, type ScanSpec, scanFiles } 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 };
@@ -170,7 +177,7 @@ export const check: RepoCheck = {
170
177
  // The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
171
178
  // default — that's a deliberate scope choice, not a vacuous scan, so it must
172
179
  // report notApplicable instead of matchedFiles: 0.
173
- if (scanRoots(SCAN, [root]).length === 0) {
180
+ if (!keepsRootKind(SCAN, root)) {
174
181
  return { violations: [], matchedFiles: 0, notApplicable: true };
175
182
  }
176
183
  const rootAbsPath = root.absPath;
@@ -42,7 +42,7 @@ const IS_TEST = /\.(?:test|integration)\.tsx?$/;
42
42
  const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
43
43
 
44
44
  // A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
45
- const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/;
45
+ const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/g;
46
46
  // A secret-like identifier. Matched only against the text LEFT of the fallback
47
47
  // (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
48
48
  const SECRET_NAME =
@@ -116,19 +116,24 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
116
116
  for (let i = 0; i < lines.length; i++) {
117
117
  const raw = lines[i] ?? "";
118
118
  const code = maskedLines[i] ?? "";
119
- const match = STRING_FALLBACK.exec(code);
120
- if (!match) continue;
121
- const literal = match[2];
122
- if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
123
- const left = code.slice(0, match.index);
124
- const name =
125
- SECRET_NAME.exec(left)?.[0] ??
126
- (SECRET_LITERAL_TOKEN.test(literal)
127
- ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
128
- : undefined);
129
- if (name === undefined) continue;
130
119
  if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
131
- hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
120
+ // Every fallback on the line is inspected; the name is read only from the text since the
121
+ // previous fallback, so an earlier assignee cannot lend its name to a later literal.
122
+ let previousEnd = 0;
123
+ for (const match of code.matchAll(STRING_FALLBACK)) {
124
+ const left = code.slice(previousEnd, match.index);
125
+ previousEnd = match.index + match[0].length;
126
+ const literal = match[2];
127
+ if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
128
+ const name =
129
+ SECRET_NAME.exec(left)?.[0] ??
130
+ (SECRET_LITERAL_TOKEN.test(literal)
131
+ ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
132
+ : undefined);
133
+ if (name === undefined) continue;
134
+ hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
135
+ break;
136
+ }
132
137
  }
133
138
  return hits;
134
139
  }
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
  }