@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.
Files changed (52) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +185 -0
  16. package/src/guard-escape-hatch-declared.ts +26 -19
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. package/src/run-ui-guards.ts +11 -2
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: prüft dass `dispatcher.write("qn:literal:...")`-Aufrufe in
4
+ * Custom-Screens gültige Write-Handler-QNs referenzieren. Tippfehler
5
+ * im QN fallen sonst erst zur Runtime als 404 auf.
6
+ *
7
+ * Validierung in zwei Stufen:
8
+ * 1. **Strukturell**: jeder QN muss `:write:` enthalten — fängt
9
+ * offensichtliche Tippfehler ("feautre:write:create").
10
+ * 2. **Gegen Manifest**: wenn `feature-manifest.json` im Repo-Root
11
+ * liegt und `writeHandlers` enthält, matched der Guard dagegen.
12
+ *
13
+ * Usage:
14
+ * bun infra/guards/guard-write-handler-qns.ts
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import * as path from "node:path";
18
+ import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
19
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
20
+ import { toKebab, VALID_QN_RE } from "./_lib/qn";
21
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
22
+
23
+ export { toKebab, VALID_QN_RE };
24
+
25
+ const ROOT = process.cwd();
26
+
27
+ const SCAN: ScanSpec = {
28
+ scope: "source",
29
+ extensions: ["tsx"],
30
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
31
+ };
32
+
33
+ // Tests nutzen absichtlich generische QNs wie dispatcher.write("x", {}).
34
+ const EXCLUDE = /(__tests__|\.test\.tsx$|\.integration\.tsx$|\/node_modules\/|\/dist\/)/;
35
+
36
+ /**
37
+ * Reads writeHandlers per manifest found under each repo root, keyed by
38
+ * the manifest's own directory. Stage-2 matching is subtree-scoped to that
39
+ * directory — prevents cross-app false positives when a repo hosts several
40
+ * independent sample apps, each with its own (or no) manifest.
41
+ */
42
+ interface ManifestEntry {
43
+ readonly baseDir: string;
44
+ readonly known: Set<string>;
45
+ }
46
+
47
+ function loadKnownQnsByRepo(roots: ReadonlyArray<RepoRoot>): Map<string, ManifestEntry[]> {
48
+ const byRepo = new Map<string, ManifestEntry[]>();
49
+
50
+ const manifestCandidates = [
51
+ "feature-manifest.json",
52
+ "samples/apps/use-all-bundled/feature-manifest.json",
53
+ ];
54
+
55
+ for (const repo of roots) {
56
+ const entries: ManifestEntry[] = [];
57
+ for (const rel of manifestCandidates) {
58
+ const manifestPath = path.join(repo.absPath, rel);
59
+ if (!existsSync(manifestPath)) continue;
60
+ try {
61
+ const raw = readFileSync(manifestPath, "utf-8");
62
+ const manifest = JSON.parse(raw) as {
63
+ readonly features?: ReadonlyArray<{
64
+ readonly writeHandlers?: readonly string[];
65
+ }>;
66
+ };
67
+ if (!manifest.features) continue;
68
+ const known = new Set<string>();
69
+ for (const f of manifest.features) {
70
+ if (f.writeHandlers) {
71
+ for (const qn of f.writeHandlers) known.add(qn);
72
+ }
73
+ }
74
+ if (known.size > 0) {
75
+ entries.push({ baseDir: path.dirname(manifestPath), known });
76
+ }
77
+ } catch (err) {
78
+ console.warn(`[WARN] Manifest ${manifestPath} not readable: ${err}`);
79
+ }
80
+ }
81
+ if (entries.length > 0) byRepo.set(repo.absPath, entries);
82
+ }
83
+ return byRepo;
84
+ }
85
+
86
+ /** Picks the manifest whose base directory is the longest prefix of `filePath`. */
87
+ function findKnownQns(filePath: string, entries: ReadonlyArray<ManifestEntry>): Set<string> {
88
+ let best: ManifestEntry | undefined;
89
+ for (const entry of entries) {
90
+ if (filePath !== entry.baseDir && !filePath.startsWith(`${entry.baseDir}${path.sep}`)) continue;
91
+ if (!best || entry.baseDir.length > best.baseDir.length) best = entry;
92
+ }
93
+ return best?.known ?? new Set<string>();
94
+ }
95
+
96
+ /**
97
+ * Extrahiert den Literal-Wert aus einem String-Literal, einem
98
+ * Backtick-Literal ohne Interpolation (`NoSubstitutionTemplateLiteral`) oder
99
+ * einem `<literal> as T`-Cast. Backtick-Konstanten (`const QN = \`x:write:y\``)
100
+ * wurden vorher nur als direktes Argument, nicht als aufgelöste Deklaration
101
+ * erkannt.
102
+ */
103
+ function literalValueOf(node: Node): string | undefined {
104
+ if (
105
+ node.isKind(SyntaxKind.StringLiteral) ||
106
+ node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
107
+ ) {
108
+ return node.getLiteralValue();
109
+ }
110
+ if (node.isKind(SyntaxKind.AsExpression)) {
111
+ return literalValueOf(node.getExpression());
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** Resolve Handler-constant refs (`Handlers.foo`) to string literals when static. */
117
+ export function resolveWriteQnFromArg(node: Node): string | undefined {
118
+ const direct = literalValueOf(node);
119
+ if (direct !== undefined) return direct;
120
+
121
+ if (!node.isKind(SyntaxKind.Identifier) && !node.isKind(SyntaxKind.PropertyAccessExpression)) {
122
+ return undefined;
123
+ }
124
+
125
+ const symbol = node.getSymbol() ?? node.getType().getSymbol();
126
+ if (!symbol) return undefined;
127
+
128
+ for (const decl of symbol.getDeclarations()) {
129
+ if (decl.isKind(SyntaxKind.PropertyAssignment) || decl.isKind(SyntaxKind.VariableDeclaration)) {
130
+ const init = decl.getInitializer();
131
+ const value = init ? literalValueOf(init) : undefined;
132
+ if (value !== undefined) return value;
133
+ }
134
+ }
135
+
136
+ return undefined;
137
+ }
138
+
139
+ /**
140
+ * Scannt eine SourceFile nach `dispatcher.write(<stringLiteral>, ...)`-
141
+ * oder `<expr>.write(<stringLiteral>, ...)`-Aufrufen.
142
+ */
143
+ export function scanDispatcherWriteCalls(
144
+ sf: SourceFile,
145
+ ): Array<{ line: number; qn: string; snippet: string }> {
146
+ const hits: Array<{ line: number; qn: string; snippet: string }> = [];
147
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
148
+ const expr = call.getExpression();
149
+ const exprText = expr.getText();
150
+
151
+ // `dispatcher.write(...)` oder irgendein `<obj>.write(...)` (ein
152
+ // aliaster Dispatcher heißt nicht zwingend "dispatcher").
153
+ const isDispatcherCall = exprText === "dispatcher.write";
154
+ if (!isDispatcherCall && !exprText.endsWith(".write")) continue;
155
+
156
+ // Erster Parameter muss ein String-Literal sein — dynamische
157
+ // QNs (Handler-Konstanten, Template-Literale) werden nicht
158
+ // validiert (sind entweder typ-safe oder nicht prüfbar).
159
+ const args = call.getArguments();
160
+ const first = args[0];
161
+ if (!first) continue;
162
+
163
+ const qn = resolveWriteQnFromArg(first);
164
+ if (qn === undefined) continue;
165
+
166
+ // `.endsWith(".write")` matcht auch Nicht-Dispatcher-Writes:
167
+ // `res.write(...)`, `stream.write(...)`, SSE `res.write("data: …")`.
168
+ // Deren Argument ist kein QN → würde sonst als "ungültiges QN-Format"
169
+ // false-positiv gemeldet. Außerhalb eines expliziten `dispatcher.write`
170
+ // nur prüfen, wenn das Literal ein Write-QN ist (enthält ":write:").
171
+ if (!isDispatcherCall && !qn.includes(":write:")) continue;
172
+
173
+ hits.push({
174
+ line: call.getStartLineNumber(),
175
+ qn,
176
+ snippet: call.getText().slice(0, 120),
177
+ });
178
+ }
179
+ return hits;
180
+ }
181
+
182
+ export const guard: AstGuard = {
183
+ name: "Write-Handler-QN Guard",
184
+ scan: SCAN,
185
+ hint: "Typo in the write-handler QN? Check feature name + handler name.",
186
+ run(files) {
187
+ const roots = resolveRepoRoots();
188
+ const knownQnsByRepo = loadKnownQnsByRepo(roots);
189
+
190
+ const violations: Array<{
191
+ file: string;
192
+ line: number;
193
+ message: string;
194
+ }> = [];
195
+
196
+ for (const sf of files) {
197
+ const filePath = sf.getFilePath();
198
+ if (EXCLUDE.test(filePath)) continue;
199
+
200
+ const repo = roots.find((r) => filePath.startsWith(`${r.absPath}${path.sep}`));
201
+ const knownQns = repo
202
+ ? findKnownQns(filePath, knownQnsByRepo.get(repo.absPath) ?? [])
203
+ : new Set<string>();
204
+
205
+ for (const hit of scanDispatcherWriteCalls(sf)) {
206
+ // Stufe 1: strukturelle Validierung
207
+ if (!VALID_QN_RE.test(hit.qn)) {
208
+ violations.push({
209
+ file: path.relative(ROOT, filePath),
210
+ line: hit.line,
211
+ message: `invalid QN format: "${hit.qn}" — must match "<feature>:write:<handler>"`,
212
+ });
213
+ continue;
214
+ }
215
+
216
+ // Stufe 2: gegen Manifest matchen (wenn vorhanden).
217
+ // QN wird vor dem Match auf kebab-case normalisiert, sodass
218
+ // camelCase- und kebab-case-Eingaben gleich behandelt werden.
219
+ const normalizedQn = toKebab(hit.qn);
220
+ if (knownQns.size > 0 && !knownQns.has(normalizedQn)) {
221
+ violations.push({
222
+ file: path.relative(ROOT, filePath),
223
+ line: hit.line,
224
+ message: `unknown write handler: "${hit.qn}" — not found in feature-manifest.json`,
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ // Info-Log wenn kein Manifest geladen wurde — kein Fehler, aber
231
+ // der Guard läuft dann nur mit struktureller Prüfung.
232
+ if (knownQnsByRepo.size === 0 && violations.length === 0) {
233
+ console.warn(
234
+ " [INFO] No feature-manifest.json with writeHandlers found — structural check only.",
235
+ );
236
+ }
237
+
238
+ return { violations };
239
+ },
240
+ };
241
+
242
+ if (import.meta.main) runStandalone(guard);
package/src/run-guards.ts CHANGED
@@ -11,27 +11,44 @@ import {
11
11
  buildSharedProject,
12
12
  explainGuards,
13
13
  isSecurityGuard,
14
+ printGuardKitBanner,
14
15
  reportResults,
15
16
  runGuards,
16
17
  } from "./_lib/guard-kit";
17
18
  import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
19
+ import { guard as asCasts } from "./check-as-casts";
20
+ import { guard as complexity } from "./check-complexity";
21
+ import { guard as predicateExtraction } from "./check-predicates";
18
22
  import { guard as accessDeniedTest } from "./guard-access-denied-test";
19
23
  import { guard as adminApi } from "./guard-admin-api";
24
+ import { guard as appFeatureStructure } from "./guard-app-feature-structure";
25
+ import { guard as brokerSubscribe } from "./guard-broker-subscribe";
20
26
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
21
27
  import { guard as directEntityWrites } from "./guard-direct-entity-writes";
22
28
  import { guard as directFetch } from "./guard-direct-fetch";
29
+ import { guard as errorReasons } from "./guard-error-reasons";
23
30
  import { guard as escapeHatchDeclared } from "./guard-escape-hatch-declared";
24
31
  import { guard as fakeTests } from "./guard-fake-tests";
25
32
  import { guard as htmlEscape } from "./guard-html-escape";
33
+ import { guard as i18nKeys } from "./guard-i18n-keys";
34
+ import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
35
+ import { guard as i18nLocaleTerminology } from "./guard-i18n-locale-terminology";
36
+ import { guard as libTestCoverage } from "./guard-lib-test-coverage";
37
+ import { guard as loadallEvents } from "./guard-loadall-events";
26
38
  import { guard as noDateApi } from "./guard-no-date-api";
27
39
  import { guard as noDirectFs } from "./guard-no-direct-fs";
28
40
  import { guard as noLogicInViews } from "./guard-no-logic-in-views";
29
41
  import { guard as openToAllReason } from "./guard-open-to-all-reason";
42
+ import { guard as piiAnnotations } from "./guard-pii-annotations";
30
43
  import { guard as preEsPatterns } from "./guard-pre-es-patterns";
31
44
  import { guard as restrictedSymbols } from "./guard-restricted-symbols";
45
+ import { guard as screenConventions } from "./guard-screen-conventions";
32
46
  import { guard as silentSkip } from "./guard-silent-skip";
47
+ import { guard as tableDdl } from "./guard-table-ddl";
33
48
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
49
+ import { guard as textFieldStance } from "./guard-text-field-stance";
34
50
  import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
51
+ import { guard as writeHandlerQns } from "./guard-write-handler-qns";
35
52
 
36
53
  export const GUARDS = [
37
54
  accessDeniedTest,
@@ -51,6 +68,22 @@ export const GUARDS = [
51
68
  restrictedSymbols,
52
69
  fakeTests,
53
70
  noLogicInViews,
71
+ brokerSubscribe,
72
+ errorReasons,
73
+ i18nKeys,
74
+ i18nLocaleMount,
75
+ i18nLocaleTerminology,
76
+ piiAnnotations,
77
+ textFieldStance,
78
+ complexity,
79
+ predicateExtraction,
80
+ screenConventions,
81
+ writeHandlerQns,
82
+ asCasts,
83
+ loadallEvents,
84
+ tableDdl,
85
+ appFeatureStructure,
86
+ libTestCoverage,
54
87
  ];
55
88
 
56
89
  // Only run on direct invocation — otherwise `import { GUARDS }` would kick
@@ -71,8 +104,8 @@ if (import.meta.main) {
71
104
  }
72
105
  const strictSecurityBaseline = process.argv.includes("--strict-security-baseline");
73
106
  const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
74
- const failed = reportResults(
75
- runGuards(guards, buildSharedProject(guards), { strictSecurityBaseline }),
76
- );
107
+ const project = buildSharedProject(guards);
108
+ printGuardKitBanner(guards.length, project);
109
+ const failed = reportResults(runGuards(guards, project, { strictSecurityBaseline }));
77
110
  process.exit(failed > 0 ? 1 : 0);
78
111
  }
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bun
2
+ import { printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
2
3
  // Standalone-`main()` guards ported as RepoCheck — run in-process, no
3
4
  // per-guard subprocess/project.
4
- import { reportResults, runRepoChecks } from "./_lib/guard-kit";
5
+ import { check as secretLiterals } from "./check-secret-literals";
6
+ import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
5
7
  import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
6
8
  import { check as primitivesDiscipline } from "./guard-primitives-discipline";
7
9
  import { check as rawSql } from "./guard-raw-sql";
8
10
  import { check as rendererBoundaries } from "./guard-renderer-boundaries";
11
+ import { check as testStackDrift } from "./guard-test-stack-drift";
9
12
  import { check as thinWrappers } from "./guard-thin-wrappers";
10
13
 
11
14
  export const REPO_CHECKS = [
@@ -14,9 +17,15 @@ export const REPO_CHECKS = [
14
17
  rendererBoundaries,
15
18
  primitivesDiscipline,
16
19
  thinWrappers,
20
+ secretLiterals,
21
+ featureIntegrationTests,
22
+ testStackDrift,
17
23
  ];
18
24
 
19
25
  if (import.meta.main) {
26
+ // No shared ts-morph Project here — RepoCheck.run() does its own file
27
+ // walk per check, so the banner omits the "Project: N files" line.
28
+ printGuardKitBanner(REPO_CHECKS.length);
20
29
  const failed = reportResults(await runRepoChecks(REPO_CHECKS));
21
30
  process.exit(failed > 0 ? 1 : 0);
22
31
  }
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  // UI-guard bundle (App-Mounting 2.0, infra#208): one process, shared ts-morph
3
3
  // Project over the UI enforcement guards.
4
- import { reportResults, runGuards } from "./_lib/guard-kit";
4
+ import {
5
+ buildSharedProject,
6
+ printGuardKitBanner,
7
+ reportResults,
8
+ runGuards,
9
+ } from "./_lib/guard-kit";
10
+ import { guard as i18nUiStrings } from "./guard-i18n-ui-strings";
5
11
  import { guard as noCustomPrimitives } from "./guard-no-custom-primitives";
6
12
  import { guard as noInlineStyles } from "./guard-no-inline-styles";
7
13
  import { guard as noRawHooks } from "./guard-no-raw-hooks";
@@ -16,10 +22,13 @@ export const UI_GUARDS = [
16
22
  noRawHooks,
17
23
  tailwindScanSurface,
18
24
  rawInteractiveElements,
25
+ i18nUiStrings,
19
26
  ];
20
27
 
21
28
  // Same as run-guards.ts: only run on direct invocation.
22
29
  if (import.meta.main) {
23
- const failed = reportResults(runGuards(UI_GUARDS));
30
+ const project = buildSharedProject(UI_GUARDS);
31
+ printGuardKitBanner(UI_GUARDS.length, project);
32
+ const failed = reportResults(runGuards(UI_GUARDS, project));
24
33
  process.exit(failed > 0 ? 1 : 0);
25
34
  }