@cosmicdrift/kumiko-guards 0.1.0 → 0.3.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 (54) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/git-env.ts +22 -0
  3. package/src/_lib/guard-kit.ts +74 -19
  4. package/src/_lib/qn.ts +21 -0
  5. package/src/_lib/roots.ts +1 -18
  6. package/src/_lib/security-baseline-cli.ts +3 -3
  7. package/src/_lib/security-baseline.ts +8 -8
  8. package/src/changes.json +40 -0
  9. package/src/check-as-casts.ts +648 -0
  10. package/src/check-complexity.ts +292 -0
  11. package/src/check-predicates.ts +218 -0
  12. package/src/check-secret-literals.ts +126 -0
  13. package/src/cli.ts +59 -0
  14. package/src/guard-admin-api.ts +1 -1
  15. package/src/guard-app-feature-structure.ts +114 -0
  16. package/src/guard-broker-subscribe.ts +99 -0
  17. package/src/guard-error-reasons.ts +185 -0
  18. package/src/guard-escape-hatch-declared.ts +26 -19
  19. package/src/guard-fake-tests.ts +1 -1
  20. package/src/guard-feature-integration-tests.ts +184 -0
  21. package/src/guard-html-escape.ts +1 -1
  22. package/src/guard-i18n-keys.ts +440 -0
  23. package/src/guard-i18n-locale-mount.ts +317 -0
  24. package/src/guard-i18n-locale-terminology.ts +117 -0
  25. package/src/guard-i18n-ui-strings.ts +248 -0
  26. package/src/guard-lib-test-coverage.ts +156 -0
  27. package/src/guard-loadall-events.ts +133 -0
  28. package/src/guard-no-custom-primitives.ts +9 -10
  29. package/src/guard-no-date-api.ts +1 -1
  30. package/src/guard-no-direct-fs.ts +1 -1
  31. package/src/guard-no-inline-styles.ts +4 -4
  32. package/src/guard-no-logic-in-views.ts +3 -3
  33. package/src/guard-no-raw-hooks.ts +4 -5
  34. package/src/guard-open-to-all-reason.ts +1 -1
  35. package/src/guard-pii-annotations.ts +267 -0
  36. package/src/guard-pre-es-patterns.ts +1 -1
  37. package/src/guard-primitives-discipline.ts +3 -3
  38. package/src/guard-raw-classname.ts +3 -3
  39. package/src/guard-raw-interactive-elements.ts +3 -3
  40. package/src/guard-raw-sql.ts +2 -2
  41. package/src/guard-renderer-boundaries.ts +1 -1
  42. package/src/guard-restricted-symbols.ts +1 -1
  43. package/src/guard-screen-conventions.ts +161 -0
  44. package/src/guard-silent-skip.ts +1 -1
  45. package/src/guard-table-ddl.ts +159 -0
  46. package/src/guard-tailwind-scan-surface.ts +12 -12
  47. package/src/guard-test-stack-drift.ts +147 -0
  48. package/src/guard-text-field-stance.ts +222 -0
  49. package/src/guard-thin-wrappers.ts +6 -1
  50. package/src/guard-unsafe-json-parse.ts +1 -1
  51. package/src/guard-write-handler-qns.ts +242 -0
  52. package/src/run-guards.ts +36 -3
  53. package/src/run-repo-checks.ts +10 -1
  54. package/src/run-ui-guards.ts +11 -2
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Table-DDL Guard (WARNUNG, kein Fail).
4
+ *
5
+ * Findet Aufrufe von `unsafePushTables` / `unsafeCreateEntityTable` /
6
+ * `unsafeEnsureEntityTable` außerhalb der erlaubten Pfade. Apps
7
+ * deklarieren Tabellen via `r.entity()` (event-sourced) oder
8
+ * `r.rawTable()` (deklarativer Bypass mit Audit-Marker) — direkte
9
+ * `unsafe*`-Aufrufe umgehen das Event-Sourcing-System komplett.
10
+ *
11
+ * Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md
12
+ * (Stufe 2). Stufe 1 hat die Symbole umbenannt damit jeder Aufruf
13
+ * grep-bar ist; Stufe 3 hat `r.rawTable()` als saubere Alternative
14
+ * eingeführt; Stufe 2 (dieser Check) erzwingt Konsistenz.
15
+ *
16
+ * Allowlist (Pfad-Regex, OR-verknüpft):
17
+ * - packages/framework/src/event-store/** ES-Meta-Tabellen
18
+ * - packages/framework/src/pipeline/event-consumer-state.ts
19
+ * - packages/framework/src/pipeline/projection-state.ts
20
+ * - packages/framework/src/stack/** Test-Stack-Helper, pushEntityProjectionTables, Helper-Definitionen
21
+ * - **\/__tests__/** Test-Setup
22
+ * - **\/drizzle/** drizzle-kit-Konfiguration
23
+ * - **\/bin/migrate.ts App-eigener Migrate-CLI
24
+ * - packages/guards/src/guard-table-ddl.ts der Guard selbst
25
+ * - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph-Rename
26
+ *
27
+ * Output: Warnung + Datei:Zeile + Code-Snippet. Blockt nie.
28
+ */
29
+
30
+ import path from "node:path";
31
+ import { type SourceFile, SyntaxKind } from "ts-morph";
32
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
33
+
34
+ const ROOT = process.cwd();
35
+
36
+ const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
37
+
38
+ export const UNSAFE_NAMES: ReadonlySet<string> = new Set([
39
+ "unsafePushTables",
40
+ "unsafeCreateEntityTable",
41
+ "unsafeEnsureEntityTable",
42
+ ]);
43
+
44
+ export const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
45
+ /\/packages\/framework\/src\/event-store\//,
46
+ /\/packages\/framework\/src\/pipeline\/event-consumer-state\.ts$/,
47
+ /\/packages\/framework\/src\/pipeline\/projection-state\.ts$/,
48
+ /\/packages\/framework\/src\/stack\//,
49
+ /\/__tests__\//,
50
+ /\/drizzle\//,
51
+ /\/bin\/migrate\.ts$/,
52
+ // Guards + Rename-Script dürfen die Symbole im Code nennen — beide
53
+ // sind Tooling, kein Runtime-Pfad.
54
+ /\/packages\/guards\/src\/guard-table-ddl\.ts$/,
55
+ /\/scripts\/migrate-rename-table-ddl\.ts$/,
56
+ ];
57
+
58
+ export function isAllowed(absPath: string): boolean {
59
+ return ALLOWLIST_PATTERNS.some((re) => re.test(absPath));
60
+ }
61
+
62
+ export interface Finding {
63
+ readonly file: string;
64
+ readonly line: number;
65
+ readonly symbol: string;
66
+ readonly snippet: string;
67
+ }
68
+
69
+ export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
70
+ const absPath = sf.getFilePath();
71
+ if (isAllowed(absPath)) return [];
72
+
73
+ const findings: Finding[] = [];
74
+ const lines = sf.getFullText().split("\n");
75
+
76
+ for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
77
+ const name = id.getText();
78
+ if (!UNSAFE_NAMES.has(name)) continue;
79
+
80
+ // Skip the import/export specifier itself — the violation is the
81
+ // call-site, not "having the name in scope". An app that imports
82
+ // unsafe* without calling it is weird but not the bypass we care
83
+ // about (and the `as` alias case still flags the call below).
84
+ const parent = id.getParent();
85
+ if (!parent) continue;
86
+ const pk = parent.getKind();
87
+ if (pk === SyntaxKind.ImportSpecifier) continue;
88
+ if (pk === SyntaxKind.ExportSpecifier) continue;
89
+
90
+ // Skip property-access right-hand-side (`obj.unsafePushTables`):
91
+ // a method on some unrelated type that happens to share the name.
92
+ if (pk === SyntaxKind.PropertyAccessExpression) {
93
+ const pae = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
94
+ if (pae.getNameNode() === id) continue;
95
+ }
96
+ if (pk === SyntaxKind.PropertyAssignment) continue;
97
+ if (pk === SyntaxKind.ShorthandPropertyAssignment) continue;
98
+ // Type-level shadows: `interface { unsafePushTables(): void }` or
99
+ // `type T = { unsafePushTables: () => void }`. Different namespace,
100
+ // not the runtime call.
101
+ if (pk === SyntaxKind.PropertySignature) {
102
+ const ps = parent.asKindOrThrow(SyntaxKind.PropertySignature);
103
+ if (ps.getNameNode() === id) continue;
104
+ }
105
+ if (pk === SyntaxKind.MethodSignature) {
106
+ const ms = parent.asKindOrThrow(SyntaxKind.MethodSignature);
107
+ if (ms.getNameNode() === id) continue;
108
+ }
109
+
110
+ const line = id.getStartLineNumber();
111
+ const raw = (lines[line - 1] ?? "").trim();
112
+ const snippet = raw.length > 100 ? `${raw.slice(0, 97)}...` : raw;
113
+ findings.push({
114
+ file: path.relative(repoRoot, absPath),
115
+ line,
116
+ symbol: name,
117
+ snippet,
118
+ });
119
+ }
120
+ return findings;
121
+ }
122
+
123
+ function report(findings: readonly Finding[], scanned: number): void {
124
+ console.log(`Table-DDL Guard: ${scanned} files checked.`);
125
+ if (findings.length === 0) {
126
+ console.log(" No bypass calls outside the allowlist.");
127
+ return;
128
+ }
129
+ console.log(` ${findings.length} bypass call(s) outside the allowlist:`);
130
+ for (const f of findings) {
131
+ console.log(` ${f.file}:${f.line} ${f.symbol}`);
132
+ console.log(` ${f.snippet}`);
133
+ }
134
+ console.log("");
135
+ console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
136
+ console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
137
+ console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
138
+ console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
139
+ console.log(" Warning, no fail.");
140
+ }
141
+
142
+ export const guard: AstGuard = {
143
+ name: "Table-DDL Guard",
144
+ scan: SCAN,
145
+ run(files) {
146
+ const findings: Finding[] = [];
147
+ let scanned = 0;
148
+ for (const sf of files) {
149
+ scanned++;
150
+ findings.push(...collectFindings(sf, ROOT));
151
+ }
152
+ report(findings, scanned);
153
+ // Warnung, kein Fail (siehe Modul-Header): Bypass-Aufrufe werden
154
+ // gemeldet, blocken den Guard-Run aber nie.
155
+ return { violations: [] };
156
+ },
157
+ };
158
+
159
+ if (import.meta.main) runStandalone(guard);
@@ -145,7 +145,7 @@ function scan(files: readonly SourceFile[]): {
145
145
  if (token.length === 0 || allowed.has(token)) continue;
146
146
  findings.push({ file, line, token });
147
147
  console.warn(
148
- ` [tailwind-scan-surface WARN] ${file}:${line} Klasse "${token}" liegt außerhalb des Tailwind-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src)`,
148
+ ` [tailwind-scan-surface WARN] ${file}:${line} class "${token}" is outside the Tailwind scan surface (renderer-web/src, renderer/src, samples/**/src)`,
149
149
  );
150
150
  }
151
151
  }
@@ -163,7 +163,7 @@ const BASELINE_FILE = ".kumiko-tailwind-scan-surface-baseline.json";
163
163
  const tailwindScanSurfaceBaseline = baselineRatchet({
164
164
  file: path.join(BASELINE_ROOT, BASELINE_FILE),
165
165
  formatVersion: 1,
166
- unit: "Klassen-Token(s)",
166
+ unit: "class token(s)",
167
167
  });
168
168
 
169
169
  // Second, independent rule: a consuming app repo (studio,
@@ -489,7 +489,7 @@ const SOURCE_COVERAGE_BASELINE_FILE = ".kumiko-tailwind-source-coverage-baseline
489
489
  const sourceCoverageBaseline = baselineRatchet({
490
490
  file: path.join(BASELINE_ROOT, SOURCE_COVERAGE_BASELINE_FILE),
491
491
  formatVersion: 1,
492
- unit: "fehlende(r) @source-Eintrag/Einträge",
492
+ unit: "missing @source entry/entries",
493
493
  });
494
494
 
495
495
  export function sourceCoverageBaselineCounts(
@@ -504,10 +504,10 @@ function checkSourceCoverageBaseline(findings: readonly SourceCoverageFinding[])
504
504
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
505
505
  return sourceCoverageBaseline.check(
506
506
  sourceCoverageBaselineCounts(findings),
507
- "Paket liefert Tailwind-Klassen ohne @source-Abdeckung in dieser App — @source-Eintrag für beide Install-Layouts ergänzen (Muster: bestehende bundled-features-Einträge).",
507
+ "Package delivers Tailwind classes without @source coverage in this appadd an @source entry for both install layouts (pattern: existing bundled-features entries).",
508
508
  {
509
509
  formatDriftRemediation:
510
- "Einmalig `bun guards/guard-tailwind-scan-surface.ts --write-baseline` aufrufen.",
510
+ "Run `bun guards/guard-tailwind-scan-surface.ts --write-baseline` once.",
511
511
  resolveLine,
512
512
  },
513
513
  );
@@ -523,10 +523,10 @@ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
523
523
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
524
524
  return tailwindScanSurfaceBaseline.check(
525
525
  baselineCounts(findings),
526
- "Klasse außerhalb des @source-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src) — eine dort bereits emittierte Klasse wiederverwenden, oder das Styling nach renderer-web ziehen.",
526
+ "Class outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer-web.",
527
527
  {
528
528
  formatDriftRemediation:
529
- "Einmalig `bun guards/guard-tailwind-scan-surface.ts --write-baseline` aufrufen.",
529
+ "Run `bun guards/guard-tailwind-scan-surface.ts --write-baseline` once.",
530
530
  resolveLine,
531
531
  },
532
532
  );
@@ -542,7 +542,7 @@ export function analyse(
542
542
  const publishedSurfaceFindings = scanPublishedScanSurface(roots);
543
543
  reportPublishedScanSurfaceFindings(publishedSurfaceFindings);
544
544
  if (!compareBaseline) {
545
- console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
545
+ console.log(" Baseline comparison skipped (--no-baseline).");
546
546
  return { violations: [] };
547
547
  }
548
548
  return {
@@ -558,10 +558,10 @@ export const guard: AstGuard = {
558
558
  name: "Tailwind-Scan-Surface Guard",
559
559
  scan: SCAN,
560
560
  hint:
561
- "Tailwind-Klasse in bundled-features außerhalb des @source-Scan-Bereichs (renderer-web/src, renderer/src, samples/**/src) " +
562
- `— reuse a class already emitted there, or move the styling into renderer-web. Begründete Ausnahme: // ${IGNORE_TAG} <Grund>. ` +
563
- "Paket ohne @source-Abdeckung in einer App: @source-Eintrag für beide Install-Layouts ergänzen. " +
564
- "@source mit node_modules/<pkg>/<segment>-Pfad: prüfen ob <pkg> dieses Segment überhaupt publiziert (package.json files).",
561
+ "Tailwind class in bundled-features outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) " +
562
+ `— reuse a class already emitted there, or move the styling into renderer-web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
563
+ "Package without @source coverage in an app: add an @source entry for both install layouts. " +
564
+ "@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
565
565
  run: (files) => analyse(files, true),
566
566
  };
567
567
 
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: *.integration.ts / *.integration.test.ts files must not construct
4
+ * a parallel server stack. Concretely: if the test calls neither
5
+ * `buildServer(...)` nor `setupTestStack(...)`, but still instantiates
6
+ * pipeline internals like `createDispatcher`, `createOutboxPoller`, or
7
+ * `createLifecycleHooks` directly, it builds its own test reality next to
8
+ * the production wiring — exactly the drift source that produces green
9
+ * tests over a broken prod path.
10
+ *
11
+ * Allowed opt-out: `// @no-server-stack: <reason>` anywhere in the file.
12
+ * Meant for pure adapter integration tests (DB, Redis, Meilisearch) that
13
+ * deliberately don't spin up a server.
14
+ *
15
+ * Usage:
16
+ * bun guards/guard-test-stack-drift.ts
17
+ */
18
+
19
+ import * as path from "node:path";
20
+ import { Project, type SourceFile, SyntaxKind } from "ts-morph";
21
+ import {
22
+ type GuardViolation,
23
+ type RepoCheck,
24
+ reportResults,
25
+ runRepoChecks,
26
+ } from "./_lib/guard-kit";
27
+ import { frameworkTsConfigPath } from "./_lib/roots";
28
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
29
+
30
+ const ROOT = process.cwd();
31
+
32
+ // Beide Suffixe: `.integration.ts` (legacy) + `.integration.test.ts` (canonical
33
+ // nach bun-test-cutover). Transition-safe — matcht main (legacy) und migrierte
34
+ // Branches gleichermaßen.
35
+ const SCAN: ScanSpec = {
36
+ scope: "tests",
37
+ extensions: ["ts"],
38
+ kinds: ["framework"],
39
+ frameworkWithin: [
40
+ "packages/framework/src/**/*.integration.ts",
41
+ "packages/framework/src/**/*.integration.test.ts",
42
+ "packages/bundled-features/src/**/*.integration.ts",
43
+ "packages/bundled-features/src/**/*.integration.test.ts",
44
+ ],
45
+ };
46
+
47
+ // Functions whose call in an integration test signals "I build the server".
48
+ // If any of these are called, the file is considered "properly wired".
49
+ const SERVER_ENTRYPOINTS = new Set(["buildServer", "setupTestStack"]);
50
+
51
+ // Pipeline-internal factories. Calling any of these WITHOUT also calling a
52
+ // server entrypoint means the test is assembling its own parallel stack.
53
+ const FORBIDDEN_WITHOUT_SERVER = new Set([
54
+ "createDispatcher",
55
+ "createOutboxPoller",
56
+ "createLifecycleHooks",
57
+ ]);
58
+
59
+ const OPT_OUT_MARKER = /\/\/\s*@no-server-stack:/i;
60
+
61
+ const INTEGRATION_FILE = /\.integration(\.test)?\.ts$/;
62
+
63
+ export function isIntegrationTestFile(filePath: string): boolean {
64
+ return INTEGRATION_FILE.test(filePath);
65
+ }
66
+
67
+ export interface Violation {
68
+ file: string;
69
+ reason: string;
70
+ forbiddenCalls: Array<{ name: string; line: number }>;
71
+ }
72
+
73
+ function collectCallNames(sf: SourceFile): Map<string, number[]> {
74
+ const calls = new Map<string, number[]>();
75
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
76
+ const name = call.getExpression().getText();
77
+ const arr = calls.get(name) ?? [];
78
+ arr.push(call.getStartLineNumber());
79
+ calls.set(name, arr);
80
+ }
81
+ return calls;
82
+ }
83
+
84
+ function hasOptOutMarker(sf: SourceFile): boolean {
85
+ return OPT_OUT_MARKER.test(sf.getFullText());
86
+ }
87
+
88
+ export function scanFile(sf: SourceFile): Violation | null {
89
+ if (hasOptOutMarker(sf)) return null;
90
+
91
+ const calls = collectCallNames(sf);
92
+
93
+ const hasServerEntrypoint = [...SERVER_ENTRYPOINTS].some((name) => calls.has(name));
94
+ if (hasServerEntrypoint) return null;
95
+
96
+ const forbiddenHits: Array<{ name: string; line: number }> = [];
97
+ for (const [name, lines] of calls) {
98
+ if (FORBIDDEN_WITHOUT_SERVER.has(name)) {
99
+ for (const line of lines) forbiddenHits.push({ name, line });
100
+ }
101
+ }
102
+
103
+ if (forbiddenHits.length === 0) return null;
104
+
105
+ return {
106
+ file: path.relative(ROOT, sf.getFilePath()),
107
+ reason: "calls pipeline internals without buildServer/setupTestStack",
108
+ forbiddenCalls: forbiddenHits,
109
+ };
110
+ }
111
+
112
+ export const check: RepoCheck = {
113
+ name: "Test-Stack-Drift Guard",
114
+ hint:
115
+ "Integration tests must call buildServer or setupTestStack — otherwise they test a different " +
116
+ "reality than prod. Pure adapter tests without a server stack: // @no-server-stack: <reason>.",
117
+ run(roots) {
118
+ if (!roots.some((r) => r.kind === "framework")) {
119
+ return { violations: [], matchedFiles: 0, notApplicable: true };
120
+ }
121
+
122
+ const project = new Project({
123
+ tsConfigFilePath: frameworkTsConfigPath(),
124
+ skipAddingFilesFromTsConfig: true,
125
+ skipFileDependencyResolution: true,
126
+ });
127
+ const paths = scanFiles(SCAN, roots);
128
+ for (const p of paths) project.addSourceFileAtPath(p);
129
+
130
+ const violations: GuardViolation[] = [];
131
+ for (const sf of project.getSourceFiles()) {
132
+ if (!isIntegrationTestFile(sf.getFilePath())) continue;
133
+ const v = scanFile(sf);
134
+ if (v === null) continue;
135
+ for (const c of v.forbiddenCalls) {
136
+ violations.push({ file: v.file, line: c.line, message: `${v.reason}: ${c.name}(...)` });
137
+ }
138
+ }
139
+
140
+ return { violations, matchedFiles: paths.length, notApplicable: false };
141
+ },
142
+ };
143
+
144
+ if (import.meta.main) {
145
+ const failed = reportResults(await runRepoChecks([check]));
146
+ process.exit(failed > 0 ? 1 : 0);
147
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: `createTextField`/`createLongTextField`-Aufrufe ohne `personal`-
4
+ * Haltung, mit Baseline-Regression-Guard wie guard-pii-annotations.ts.
5
+ *
6
+ * kumiko-framework#2810: `createTextField`/`createLongTextField` sollen
7
+ * fail-closed werfen, wenn kein `personal` (Muster #2558) deklariert ist.
8
+ * Workspace-weit fehlt `personal` an hunderten Call-Sites — ein Throw würde
9
+ * den Build sofort brechen. Dieser Guard deckt die Vollmenge (jeder Aufruf)
10
+ * ab, `guard-pii-annotations.ts` nur die Namens-Heuristik (PII-verdächtige
11
+ * Feldnamen) — die beiden Baselines sind bewusst getrennt.
12
+ *
13
+ * `.kumiko-text-field-stance-baseline.json` im Repo-Root pinnt pro File die
14
+ * eingefrorene Fund-Anzahl:
15
+ * - aktuell <= baseline pro File: PASS
16
+ * - aktuell > baseline pro File: FAIL (neuer Aufruf ohne personal-Haltung)
17
+ * Reduktionen updaten die Baseline NICHT automatisch — nach Annotations-
18
+ * Commits `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Guard
19
+ * warning-only (Bootstrap: einmalig `--write-baseline`).
20
+ *
21
+ * Usage:
22
+ * bun guards/guard-text-field-stance.ts # Vergleich gegen Baseline
23
+ * bun guards/guard-text-field-stance.ts --write-baseline # Baseline neu schreiben
24
+ * bun guards/guard-text-field-stance.ts --no-baseline # Vergleich überspringen
25
+ */
26
+ import * as path from "node:path";
27
+ import {
28
+ type CallExpression,
29
+ type Node,
30
+ type ObjectLiteralExpression,
31
+ type SourceFile,
32
+ SyntaxKind,
33
+ } from "ts-morph";
34
+ import {
35
+ type AstGuard,
36
+ baselineRatchet,
37
+ buildSharedProject,
38
+ filesForGuard,
39
+ type GuardOutcome,
40
+ type GuardViolation,
41
+ isLocalFinding,
42
+ runStandalone,
43
+ type ScanSpec,
44
+ } from "./_lib/guard-kit";
45
+
46
+ const ROOT = process.cwd();
47
+
48
+ const SCAN: ScanSpec = {
49
+ scope: "source",
50
+ extensions: ["ts"],
51
+ frameworkWithin: ["packages/*/src/**"],
52
+ };
53
+ // Unlike guard-pii-annotations.ts, tests are IN scope: kumiko-framework#2810's
54
+ // fail-closed throw fires at call time regardless of test vs. production code —
55
+ // a test fixture calling createTextField() without `personal` breaks the same
56
+ // way prod code would once the throw lands.
57
+ const EXCLUDE = /\.d\.ts$/;
58
+
59
+ const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]);
60
+
61
+ const VALID_PERSONAL_HINT =
62
+ '{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
63
+
64
+ function relFile(sf: SourceFile): string {
65
+ return path.relative(ROOT, sf.getFilePath());
66
+ }
67
+
68
+ function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
69
+ const first = call.getArguments()[0];
70
+ return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
71
+ }
72
+
73
+ // A spread's source (e.g. `{ ...base, maxLength: 5 }`) may supply `personal`
74
+ // without a literal property here — statically undecidable like a non-object
75
+ // argument, so treat it the same way (not countable).
76
+ function hasSpreadProperty(obj: ObjectLiteralExpression): boolean {
77
+ return obj.getProperties().some((prop) => prop.isKind(SyntaxKind.SpreadAssignment));
78
+ }
79
+
80
+ // Mirrors guard-pii-annotations.ts's `personal` branch: every value counts as
81
+ // answered, including `personal: false` (the framework requires a `reason`
82
+ // for it) — except `undefined`/`null`, which never answered the question.
83
+ function hasPersonalStance(obj: ObjectLiteralExpression): boolean {
84
+ const prop = obj.getProperty("personal");
85
+ if (!prop || !prop.isKind(SyntaxKind.PropertyAssignment)) return false;
86
+ const init: Node | undefined = prop.getInitializer();
87
+ return (
88
+ init !== undefined &&
89
+ init.getKind() !== SyntaxKind.UndefinedKeyword &&
90
+ !(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
91
+ init.getKind() !== SyntaxKind.NullKeyword
92
+ );
93
+ }
94
+
95
+ function enclosingFieldName(call: CallExpression): string | null {
96
+ let node = call.getParent();
97
+ while (node) {
98
+ if (node.isKind(SyntaxKind.PropertyAssignment)) {
99
+ return node.getName();
100
+ }
101
+ node = node.getParent();
102
+ }
103
+ return null;
104
+ }
105
+
106
+ export interface Finding {
107
+ file: string;
108
+ line: number;
109
+ place: string;
110
+ callee: string;
111
+ }
112
+
113
+ function scanFieldFactories(sf: SourceFile): Finding[] {
114
+ const findings: Finding[] = [];
115
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
116
+ const callee = call.getExpression().getText();
117
+ if (!FIELD_FACTORY_CALLEES.has(callee)) continue;
118
+
119
+ const args = call.getArguments();
120
+ if (args.length > 0) {
121
+ const options = fieldFactoryOptions(call);
122
+ if (!options) continue; // non-literal argument (variable/spread) — not statically decidable
123
+ if (hasSpreadProperty(options)) continue;
124
+ if (hasPersonalStance(options)) continue;
125
+ }
126
+
127
+ const place = enclosingFieldName(call) ?? `${callee}(...)`;
128
+ const line = call.getStartLineNumber();
129
+ findings.push({ file: relFile(sf), line, place, callee });
130
+ console.warn(
131
+ ` [text-field-stance WARN] ${relFile(sf)}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
132
+ );
133
+ }
134
+ return findings;
135
+ }
136
+
137
+ function scan(files: readonly SourceFile[]): {
138
+ findings: Finding[];
139
+ scanned: number;
140
+ } {
141
+ const findings: Finding[] = [];
142
+ let scanned = 0;
143
+ for (const sf of files) {
144
+ if (EXCLUDE.test(sf.getFilePath())) continue;
145
+ scanned++;
146
+ findings.push(...scanFieldFactories(sf));
147
+ }
148
+ return { findings, scanned };
149
+ }
150
+
151
+ function localFindings(findings: readonly Finding[]): readonly Finding[] {
152
+ return findings.filter(isLocalFinding);
153
+ }
154
+
155
+ function countByFile(findings: readonly Finding[]): Record<string, number> {
156
+ const counts: Record<string, number> = {};
157
+ for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
158
+ return counts;
159
+ }
160
+
161
+ const BASELINE_FILE = ".kumiko-text-field-stance-baseline.json";
162
+ const textFieldStanceBaseline = baselineRatchet({
163
+ file: path.join(ROOT, BASELINE_FILE),
164
+ formatVersion: 1,
165
+ unit: "finding(s) without personal stance",
166
+ });
167
+
168
+ // One place for "what goes into the baseline", used by both the compare and
169
+ // the write path — a sibling finding leaking into the written file would turn
170
+ // a foreign checkout's code into a local, unfixable red.
171
+ export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
172
+ return countByFile(localFindings(findings));
173
+ }
174
+
175
+ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
176
+ const local = localFindings(findings);
177
+ const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
178
+ return textFieldStanceBaseline.check(
179
+ baselineCounts(findings),
180
+ `Annotate the call (${VALID_PERSONAL_HINT}).`,
181
+ {
182
+ formatDriftRemediation: `Run \`bun guards/guard-text-field-stance.ts --write-baseline\` once.`,
183
+ resolveLine,
184
+ },
185
+ );
186
+ }
187
+
188
+ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
189
+ const { findings } = scan(files);
190
+ if (!compareBaseline) {
191
+ console.log(" Baseline comparison skipped (--no-baseline).");
192
+ return { violations: [] };
193
+ }
194
+ return { violations: checkBaseline(findings) };
195
+ }
196
+
197
+ export const guard: AstGuard = {
198
+ name: "Text-Field Personal-Stance Guard",
199
+ scan: SCAN,
200
+ hint: "after a deliberate annotation: `bun guards/guard-text-field-stance.ts --write-baseline`",
201
+ run: (files) => analyse(files, true),
202
+ };
203
+
204
+ // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
205
+ // (run-guards.ts) faehrt alle Guards mit derselben argv, ein
206
+ // --write-baseline dort duerfte die Baseline nicht stillschweigend
207
+ // neu schreiben.
208
+ if (import.meta.main) {
209
+ const args = process.argv.slice(2);
210
+ if (args.includes("--write-baseline")) {
211
+ const project = buildSharedProject([guard]);
212
+ const { findings } = scan(filesForGuard(project, guard));
213
+ textFieldStanceBaseline.write(baselineCounts(findings));
214
+ process.exit(0);
215
+ }
216
+ if (args.includes("--no-baseline")) {
217
+ const project = buildSharedProject([guard]);
218
+ analyse(filesForGuard(project, guard), false);
219
+ process.exit(0);
220
+ }
221
+ runStandalone(guard);
222
+ }
@@ -299,7 +299,12 @@ function extractCalleeName(callNode: ReturnType<FnNode["getBody"]> | undefined):
299
299
  const expr = callNode.isKind(SyntaxKind.CallExpression) ? callNode.getExpression() : null;
300
300
  if (!expr) return null;
301
301
  if (expr.isKind(SyntaxKind.Identifier)) return expr.getText();
302
- if (expr.isKind(SyntaxKind.PropertyAccessExpression)) return expr.getName();
302
+ if (expr.isKind(SyntaxKind.PropertyAccessExpression)) {
303
+ // `/re/.test(x)` is a RegExp method call, not a call to a function named
304
+ // "test" — a regex-literal receiver is never a wrapper callee.
305
+ if (expr.getExpression().isKind(SyntaxKind.RegularExpressionLiteral)) return null;
306
+ return expr.getName();
307
+ }
303
308
  return null;
304
309
  }
305
310
 
@@ -66,7 +66,7 @@ function scanFile(sf: SourceFile): UnsafeSite[] {
66
66
  export const guard: AstGuard = {
67
67
  name: "Unsafe-JSON-Parse Guard",
68
68
  scan: SCAN,
69
- hint: "Nutze parseJsonSafe (Cache-Semantik) oder parseJsonOrThrow (Boundary-Semantik) aus utils/safe-json.",
69
+ hint: "Use parseJsonSafe (cache semantics) or parseJsonOrThrow (boundary semantics) from utils/safe-json.",
70
70
  run(files) {
71
71
  const violations: Array<{ file: string; line: number; message: string }> = [];
72
72
  for (const sf of files) {