@cosmicdrift/kumiko-guards 0.1.1 → 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.
- package/package.json +4 -1
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +32 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
|
@@ -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}
|
|
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: "
|
|
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: "
|
|
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
|
-
"
|
|
507
|
+
"Package delivers Tailwind classes without @source coverage in this app — add an @source entry for both install layouts (pattern: existing bundled-features entries).",
|
|
508
508
|
{
|
|
509
509
|
formatDriftRemediation:
|
|
510
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
|
562
|
-
`— reuse a class already emitted there, or move the styling into renderer-web.
|
|
563
|
-
"
|
|
564
|
-
"@source
|
|
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))
|
|
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: "
|
|
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) {
|